envseal 0.3.10

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Linux GUI dialog backend — drives the user's installed `zenity` or
//! `kdialog` binary to render the passphrase, secret-value, approval,
//! TOTP, and preexec prompts. The dialog binary is verified
//! root-owned via [`crate::guard::verify_gui_binary`] before each
//! invocation so a path-shadowed clone cannot intercept the
//! passphrase.

#![cfg(target_os = "linux")]

use super::Approval;
use crate::error::Error;
use crate::guard;
use std::process::Command;
use zeroize::Zeroizing;

/// Which dialog backend was found on this host. Picked once per call
/// by [`resolve_linux_dialog`].
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum DialogKind {
    /// GNOME / GTK-style dialog tool.
    Zenity,
    /// KDE-style dialog tool.
    Kdialog,
}

pub(crate) fn resolve_linux_dialog() -> Result<(std::path::PathBuf, DialogKind), Error> {
    if let Ok(path) = guard::verify_gui_binary("zenity") {
        return Ok((path, DialogKind::Zenity));
    }
    if let Ok(path) = guard::verify_gui_binary("kdialog") {
        return Ok((path, DialogKind::Kdialog));
    }
    Err(Error::NoDisplay)
}

pub(crate) fn escape_linux(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

pub(crate) fn linux_passphrase(
    is_new: bool,
    prev_error: Option<&str>,
) -> Result<Zeroizing<String>, Error> {
    if is_new {
        return linux_passphrase_create(prev_error);
    }
    let base_text = match prev_error {
        Some(err) => format!("{err}\n\nEnter vault passphrase:"),
        None => "Enter vault passphrase:".to_string(),
    };
    let (dialog_path, kind) = resolve_linux_dialog()?;
    let output = match kind {
        DialogKind::Zenity => Command::new(&dialog_path)
            .args(["--password", "--title=envseal"])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("zenity failed: {e}")))?,
        DialogKind::Kdialog => Command::new(&dialog_path)
            .args(["--password", &base_text, "--title", "envseal"])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("kdialog failed: {e}")))?,
    };
    if !output.status.success() {
        return Err(Error::UserDenied);
    }
    let pass = String::from_utf8_lossy(&output.stdout)
        .trim_end_matches(&['\n', '\r'][..])
        .to_string();
    if pass.is_empty() {
        Err(Error::UserDenied)
    } else {
        Ok(Zeroizing::new(pass))
    }
}

/// Create-and-confirm flow for Linux. zenity supports `--forms` with
/// multiple `--add-password` fields rendered in a single dialog —
/// match Windows-grade UX. kdialog has no forms support, so it
/// falls back to two sequential prompts. Either way: loop up to 3
/// attempts on mismatch so a single typo doesn't bounce the
/// operator all the way back to the CLI.
fn linux_passphrase_create(prev_error: Option<&str>) -> Result<Zeroizing<String>, Error> {
    let (dialog_path, kind) = resolve_linux_dialog()?;
    let mut hint = prev_error.map(str::to_string);
    for _attempt in 0..3 {
        let result = match kind {
            DialogKind::Zenity => zenity_two_field(&dialog_path, hint.as_deref())?,
            DialogKind::Kdialog => kdialog_two_prompt(&dialog_path, hint.as_deref())?,
        };
        let (first, confirm) = result;
        if first.as_str() == confirm.as_str() {
            return Ok(first);
        }
        hint = Some("Passphrases did not match. Try again.".to_string());
    }
    Err(Error::UserDenied)
}

/// zenity `--forms --add-password=… --add-password=…` rendered as a
/// single dialog with both fields visible at once. Output is the two
/// passwords joined by a unit-separator (`\x1f`) so a passphrase
/// containing the default `|` separator can't split the parse.
fn zenity_two_field(
    dialog_path: &std::path::Path,
    prev_error: Option<&str>,
) -> Result<(Zeroizing<String>, Zeroizing<String>), Error> {
    let header = match prev_error {
        Some(err) => format!("{err}\n\nCreate a vault passphrase (min 8 chars):"),
        None => "Create a vault passphrase (min 8 chars):".to_string(),
    };
    let output = Command::new(dialog_path)
        .args([
            "--forms",
            "--title=envseal",
            &format!("--text={header}"),
            "--add-password=Passphrase",
            "--add-password=Confirm",
            "--separator=\u{001f}",
        ])
        .output()
        .map_err(|e| Error::CryptoFailure(format!("zenity failed: {e}")))?;
    if !output.status.success() {
        return Err(Error::UserDenied);
    }
    let raw = String::from_utf8_lossy(&output.stdout);
    let line = raw.trim_end_matches(&['\n', '\r'][..]);
    let mut parts = line.splitn(2, '\u{001f}');
    let first = parts.next().unwrap_or("").to_string();
    let confirm = parts.next().unwrap_or("").to_string();
    if first.is_empty() || confirm.is_empty() {
        return Err(Error::UserDenied);
    }
    Ok((Zeroizing::new(first), Zeroizing::new(confirm)))
}

/// kdialog two-sequential-prompt fallback. kdialog has no
/// multi-field form, so we issue two separate `--password` calls
/// and compare in Rust. Less polished than the zenity path but
/// still safer than the v0.2.0 single-prompt-no-confirm flow.
fn kdialog_two_prompt(
    dialog_path: &std::path::Path,
    prev_error: Option<&str>,
) -> Result<(Zeroizing<String>, Zeroizing<String>), Error> {
    let prompt = match prev_error {
        Some(err) => format!("{err}\n\nCreate a vault passphrase (min 8 chars):"),
        None => "Create a vault passphrase (min 8 chars):".to_string(),
    };
    let first = run_password_dialog(dialog_path, DialogKind::Kdialog, &prompt)?;
    let confirm = run_password_dialog(
        dialog_path,
        DialogKind::Kdialog,
        "Confirm passphrase (must match exactly):",
    )?;
    Ok((first, confirm))
}

fn run_password_dialog(
    dialog_path: &std::path::Path,
    kind: DialogKind,
    prompt: &str,
) -> Result<Zeroizing<String>, Error> {
    let output = match kind {
        DialogKind::Zenity => Command::new(dialog_path)
            .args(["--password", "--title=envseal", &format!("--text={prompt}")])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("zenity failed: {e}")))?,
        DialogKind::Kdialog => Command::new(dialog_path)
            .args(["--password", prompt, "--title", "envseal"])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("kdialog failed: {e}")))?,
    };
    if !output.status.success() {
        return Err(Error::UserDenied);
    }
    let pass = String::from_utf8_lossy(&output.stdout)
        .trim_end_matches(&['\n', '\r'][..])
        .to_string();
    if pass.is_empty() {
        Err(Error::UserDenied)
    } else {
        Ok(Zeroizing::new(pass))
    }
}

pub(crate) fn linux_secret_value(
    key_name: &str,
    description: &str,
) -> Result<Zeroizing<String>, Error> {
    let (dialog_path, kind) = resolve_linux_dialog()?;
    let text = format!(
        "Paste value for secret '{}':\n{}",
        escape_linux(key_name),
        escape_linux(description)
    );
    let output = match kind {
        DialogKind::Zenity => Command::new(&dialog_path)
            .args([
                "--entry",
                "--title=envseal — Store Secret",
                &format!("--text={text}"),
                "--width=400",
            ])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("zenity failed: {e}")))?,
        DialogKind::Kdialog => Command::new(&dialog_path)
            .args(["--inputbox", &text, "--title", "envseal — Store Secret"])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("kdialog failed: {e}")))?,
    };
    if !output.status.success() {
        return Err(Error::UserDenied);
    }
    let pass = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if pass.is_empty() {
        Err(Error::UserDenied)
    } else {
        Ok(Zeroizing::new(pass))
    }
}

pub(crate) fn linux_popup(
    binary_path: &str,
    command: &str,
    secret_name: &str,
    env_var: &str,
    warnings: &str,
) -> Result<Approval, Error> {
    let (dialog_path, kind) = resolve_linux_dialog()?;
    let text = format!("Approval needed to inject secret.\n\nBinary: {binary_path}\nCommand: {command}\nSecret: {secret_name}\nEnv Var: {env_var}\n{warnings}");
    let output = match kind {
        DialogKind::Zenity => Command::new(&dialog_path)
            .args([
                "--question",
                "--title=envseal — Inject Secret",
                &format!("--text={text}"),
                "--ok-label=Allow Once",
                "--cancel-label=Deny",
                "--extra-button=Allow Always",
                "--width=400",
            ])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("zenity failed: {e}")))?,
        DialogKind::Kdialog => Command::new(&dialog_path)
            .args([
                "--yesnocancel",
                &text,
                "--title",
                "envseal — Inject Secret",
                "--yes-label",
                "Allow Once",
                "--no-label",
                "Allow Always",
                "--cancel-label",
                "Deny",
            ])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("kdialog failed: {e}")))?,
    };
    if kind == DialogKind::Zenity {
        let stdout = String::from_utf8_lossy(&output.stdout);
        if stdout.contains("Allow Always") {
            return Ok(Approval::AllowAlways);
        }
        if output.status.success() {
            return Ok(Approval::AllowOnce);
        }
        Err(Error::UserDenied)
    } else {
        match output.status.code() {
            Some(0) => Ok(Approval::AllowOnce),
            Some(1) => Ok(Approval::AllowAlways),
            _ => Err(Error::UserDenied),
        }
    }
}

pub(crate) fn linux_preexec_prompt(message: &str) -> Result<super::PreexecChoice, Error> {
    let (dialog_path, kind) = resolve_linux_dialog()?;
    let text = escape_linux(message);
    let output = match kind {
        DialogKind::Zenity => Command::new(&dialog_path)
            .args([
                "--question",
                "--title=envseal — Migrate API key",
                &format!("--text={text}"),
                "--ok-label=Store in vault",
                "--cancel-label=Not now",
                "--extra-button=Don't ask again",
                "--width=520",
            ])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("zenity failed: {e}")))?,
        DialogKind::Kdialog => Command::new(&dialog_path)
            .args([
                "--yesnocancel",
                &text,
                "--title",
                "envseal — Migrate API key",
                "--yes-label",
                "Store in vault",
                "--no-label",
                "Don't ask again",
                "--cancel-label",
                "Not now",
            ])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("kdialog failed: {e}")))?,
    };
    if kind == DialogKind::Zenity {
        let stdout = String::from_utf8_lossy(&output.stdout);
        if stdout.contains("Don't ask again") {
            return Ok(super::PreexecChoice::DontAskAgain);
        }
        if output.status.success() {
            return Ok(super::PreexecChoice::Store);
        }
        Ok(super::PreexecChoice::Skip)
    } else {
        match output.status.code() {
            Some(0) => Ok(super::PreexecChoice::Store),
            Some(1) => Ok(super::PreexecChoice::DontAskAgain),
            _ => Ok(super::PreexecChoice::Skip),
        }
    }
}

pub(crate) fn linux_totp_entry(attempt: u32) -> Result<String, Error> {
    let (dialog_path, kind) = resolve_linux_dialog()?;
    let text = format!("Enter your 6-digit authenticator code (attempt {attempt}/3)");
    let output = match kind {
        DialogKind::Zenity => Command::new(&dialog_path)
            .args([
                "--entry",
                "--title=envseal — TOTP Verification",
                &format!("--text={text}"),
                "--width=350",
            ])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("zenity failed: {e}")))?,
        DialogKind::Kdialog => Command::new(&dialog_path)
            .args([
                "--inputbox",
                &text,
                "--title",
                "envseal — TOTP Verification",
            ])
            .output()
            .map_err(|e| Error::CryptoFailure(format!("kdialog failed: {e}")))?,
    };
    if !output.status.success() {
        return Err(Error::UserDenied);
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}