#![cfg(target_os = "linux")]
use super::Approval;
use crate::error::Error;
use crate::guard;
use std::process::Command;
use zeroize::Zeroizing;
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum DialogKind {
Zenity,
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('&', "&")
.replace('<', "<")
.replace('>', ">")
}
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))
}
}
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)
}
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)))
}
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())
}