diff --git a/Cargo.lock b/Cargo.lock index 31c56677ac..b48dfe2dd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,6 +954,7 @@ dependencies = [ "smol", "tempfile", "util", + "which 6.0.3", "windows 0.61.3", "zeroize", ] diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index 298d1a7369..671ab34df2 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -22,6 +22,9 @@ tempfile.workspace = true util.workspace = true zeroize.workspace = true +[target.'cfg(not(target_os = "windows"))'.dependencies] +which.workspace = true + [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index e887841b58..dc5488ac11 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -91,6 +91,9 @@ pub struct AskPassSession { #[cfg(not(target_os = "windows"))] const ASKPASS_SCRIPT_NAME: &str = "askpass.sh"; +#[cfg(not(target_os = "windows"))] +const GPG_WRAPPER_SCRIPT_NAME: &str = "gpg-wrapper.sh"; + impl AskPassSession { /// This will create a new AskPassSession. /// You must retain this session until the master process exits. @@ -189,6 +192,15 @@ impl AskPassSession { self.askpass_task.script_path() } + /// Path to a script suitable for git's `gpg.program`, routing GnuPG + /// passphrase prompts through Zed's askpass UI. `None` if unavailable. + pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> { + #[cfg(not(target_os = "windows"))] + return self.askpass_task.gpg_wrapper_path(); + #[cfg(target_os = "windows")] + return None; + } + /// Returns the socket path to set as ZED_ASKPASS_SOCKET. /// /// On Windows, SSH_ASKPASS points directly to cli.exe. SSH passes only @@ -206,6 +218,8 @@ pub struct PasswordProxy { /// On Unix: path to the generated .sh askpass script (set as SSH_ASKPASS). /// On Windows: path to cli.exe (set as SSH_ASKPASS directly — no script needed). askpass_script_path: std::path::PathBuf, + #[cfg(not(target_os = "windows"))] + gpg_wrapper_script_path: Option, /// On Windows only: path to the Unix socket, passed as ZED_ASKPASS_SOCKET /// so cli.exe can find it without --askpass argument parsing. #[cfg(target_os = "windows")] @@ -238,6 +252,24 @@ impl PasswordProxy { let askpass_socket_path = askpass_socket.clone(); + // Create a gpg wrapper script that routes GnuPG passphrase prompts through + // the same socket (and thus through Zed's askpass UI). This only works on + // Unix where we control the pinentry via loopback mode. We compute the path + // before the socket task takes ownership of `temp_dir`, and write the file + // afterwards. + #[cfg(not(target_os = "windows"))] + let (gpg_wrapper_script_path, gpg_wrapper_script) = + match generate_gpg_wrapper_script(askpass_program, &askpass_socket_path) { + Ok(script) => ( + Some(temp_dir.path().join(GPG_WRAPPER_SCRIPT_NAME)), + Some(script), + ), + Err(err) => { + log::warn!("could not create gpg askpass wrapper: {err:#}"); + (None, None) + } + }; + let _task = executor.spawn(async move { maybe!(async move { let listener = @@ -291,9 +323,36 @@ impl PasswordProxy { })?; } + // Write the gpg wrapper script (computed above) and mark it executable. + #[cfg(not(target_os = "windows"))] + let gpg_wrapper_script_path = + if let Some((path, script)) = gpg_wrapper_script_path.zip(gpg_wrapper_script) { + match async { + fs::write(&path, script) + .await + .with_context(|| format!("creating gpg wrapper script at {path:?}"))?; + make_file_executable(&path).await.with_context(|| { + format!("marking gpg wrapper script executable at {path:?}") + })?; + anyhow::Ok(()) + } + .await + { + Ok(()) => Some(path), + Err(err) => { + log::warn!("could not write gpg askpass wrapper: {err:#}"); + None + } + } + } else { + None + }; + Ok(Self { _task, askpass_script_path, + #[cfg(not(target_os = "windows"))] + gpg_wrapper_script_path, #[cfg(target_os = "windows")] askpass_socket_path, }) @@ -307,6 +366,11 @@ impl PasswordProxy { pub fn socket_path(&self) -> impl AsRef { &self.askpass_socket_path } + + #[cfg(not(target_os = "windows"))] + pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> { + self.gpg_wrapper_script_path.as_deref() + } } /// Runs Zed in netcat mode for use in askpass. @@ -398,3 +462,75 @@ fn generate_askpass_script( "{shebang}\n{print_args} | {askpass_program} --askpass={askpass_socket} 2> /dev/null \n", )) } + +#[inline] +#[cfg(not(target_os = "windows"))] +fn generate_gpg_wrapper_script( + askpass_program: &std::path::Path, + askpass_socket: &std::path::Path, +) -> Result { + let shell_kind = ShellKind::Posix; + let gpg_program = find_gpg_program().context("could not find a gpg binary on PATH")?; + let gpg_program = gpg_program + .to_str() + .context("gpg program is on a non-utf8 path")?; + let gpg_program = shell_kind + .try_quote_prefix_aware(gpg_program) + .context("Failed to shell-escape gpg program path")?; + + let askpass_program = shell_kind.prepend_command_prefix( + askpass_program + .to_str() + .context("Askpass program is on a non-utf8 path")?, + ); + let askpass_program = shell_kind + .try_quote_prefix_aware(&askpass_program) + .context("Failed to shell-escape Askpass program path")?; + let askpass_socket = askpass_socket + .try_shell_safe(shell_kind) + .context("Failed to shell-escape Askpass socket path")?; + + let prompt = shell_kind + .try_quote_prefix_aware("Enter passphrase for your Git signing key:") + .context("Failed to shell-escape gpg passphrase prompt")?; + + // The wrapper inspects gpg's arguments and only prompts for a passphrase when + // git asks it to *sign* (e.g. `gpg -bsau `). Other invocations such as + // signature verification (`--verify`) run gpg unchanged so we never pop a + // spurious modal. When signing, we feed the passphrase to gpg on fd 3 via + // `--passphrase-fd 3` in loopback mode, so no pinentry/terminal is required. + Ok(format!( + r#"#!/bin/sh +for arg in "$@"; do + case "$arg" in + # Long-form signing options. + --sign|--detach-sign|--clearsign|--clear-sign) is_signing=1 ;; + # Skip other long options so flags like `--status-fd` don't match below. + --*) ;; + # Short-flag clusters containing `s`, e.g. git's `-bsau`. + -*s*) is_signing=1 ;; + esac +done + +# Not a signing request: run gpg as-is, leaving its prompting untouched. +if [ -z "${{is_signing}}" ]; then + exec {gpg_program} "$@" +fi + +# Signing: ask Zed for the passphrase, then hand it to gpg on fd 3. +passphrase=$(printf '%s\0' {prompt} | {askpass_program} --askpass={askpass_socket} 2>/dev/null) +exec {gpg_program} --pinentry-mode loopback --passphrase-fd 3 "$@" 3< Option { + ["gpg", "gpg2"] + .into_iter() + .find_map(|candidate| which::which(candidate).ok()) +} diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 94d3fac855..0ff60eade2 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -3744,6 +3744,16 @@ async fn run_git_command( .env("GIT_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS_REQUIRE", "force"); + + if !env.contains_key("GIT_CONFIG_COUNT") + && let Some(gpg_wrapper) = ask_pass.gpg_wrapper_path() + { + command + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "gpg.program") + .env("GIT_CONFIG_VALUE_0", gpg_wrapper); + } + #[cfg(target_os = "windows")] command.env("ZED_ASKPASS_SOCKET", ask_pass.socket_path()); let git_process = command.spawn()?;