use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::process::Command;
use tokio::time::timeout;
use crate::GitwayError;
const ASKPASS_TIMEOUT: Duration = Duration::from_secs(60);
pub async fn confirm(prompt: &str) -> bool {
let Some(askpass_raw) = std::env::var_os("SSH_ASKPASS") else {
log::warn!(
"gitway-agent: sign request for confirm-required key rejected — \
SSH_ASKPASS is not set"
);
return false;
};
match confirm_with(&askpass_raw, prompt).await {
Ok(true) => true,
Ok(false) => {
log::info!("gitway-agent: user denied sign request via askpass");
false
}
Err(e) => {
log::warn!("gitway-agent: askpass confirm failed: {e}");
false
}
}
}
pub async fn confirm_with(askpass: &OsString, prompt: &str) -> Result<bool, GitwayError> {
let path = PathBuf::from(askpass);
validate_security(&path)?;
let mut cmd = Command::new(&path);
cmd.arg(prompt)
.env("SSH_ASKPASS_PROMPT", "confirm")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let status = match timeout(ASKPASS_TIMEOUT, cmd.status()).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
return Err(GitwayError::signing(format!(
"askpass spawn failed for {}: {e}",
path.display()
)));
}
Err(_elapsed) => {
return Err(GitwayError::signing(format!(
"askpass {} did not respond within {:?}",
path.display(),
ASKPASS_TIMEOUT
)));
}
};
Ok(status.success())
}
fn validate_security(askpass: &Path) -> Result<(), GitwayError> {
if !askpass.is_absolute() {
return Err(GitwayError::invalid_config(format!(
"SSH_ASKPASS {} must be an absolute path",
askpass.display()
)));
}
let meta = std::fs::metadata(askpass).map_err(|e| {
GitwayError::invalid_config(format!(
"SSH_ASKPASS {} cannot be stat()ed: {e}",
askpass.display()
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
if meta.permissions().mode() & 0o002 != 0 {
return Err(GitwayError::invalid_config(format!(
"SSH_ASKPASS {} is world-writable and cannot be trusted",
askpass.display()
)));
}
}
#[cfg(not(unix))]
{
let _ = meta;
}
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt as _;
use tempfile::TempDir;
fn fixture(dir: &TempDir, name: &str, exit_code: i32) -> OsString {
let path = dir.path().join(name);
fs::write(&path, format!("#!/bin/sh\nexit {exit_code}\n")).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
path.into_os_string()
}
#[tokio::test]
async fn approves_when_askpass_exits_zero() {
let dir = TempDir::new().unwrap();
let yes = fixture(&dir, "yes", 0);
let approved = confirm_with(&yes, "allow?").await.unwrap();
assert!(approved);
}
#[tokio::test]
async fn denies_when_askpass_exits_nonzero() {
let dir = TempDir::new().unwrap();
let no = fixture(&dir, "no", 1);
let approved = confirm_with(&no, "allow?").await.unwrap();
assert!(!approved);
}
#[tokio::test]
async fn rejects_relative_path() {
let raw = OsString::from("relative-askpass.sh");
let err = confirm_with(&raw, "allow?").await.unwrap_err();
assert!(
err.to_string().contains("absolute"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn rejects_world_writable_askpass() {
let dir = TempDir::new().unwrap();
let yes = fixture(&dir, "leaky", 0);
fs::set_permissions(Path::new(&yes), fs::Permissions::from_mode(0o757)).unwrap();
let err = confirm_with(&yes, "allow?").await.unwrap_err();
assert!(
err.to_string().contains("world-writable"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn reports_missing_askpass() {
let raw = OsString::from("/definitely/does/not/exist/askpass.sh");
let err = confirm_with(&raw, "allow?").await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("stat()ed") || msg.contains("No such"),
"unexpected error: {msg}"
);
}
}