use std::path::PathBuf;
use toride_ssh_core::{Error, Result};
use zeroize::Zeroize;
pub struct AskpassHandler {
script_path: PathBuf,
}
impl AskpassHandler {
pub fn new(passphrase: &str) -> Result<Self> {
let script_path = Self::create_script_in(passphrase, &std::env::temp_dir())?;
Ok(Self { script_path })
}
#[cfg(test)]
fn new_in_dir(passphrase: &str, dir: &std::path::Path) -> Result<Self> {
let script_path = Self::create_script_in(passphrase, dir)?;
Ok(Self { script_path })
}
#[must_use]
pub fn script_path(&self) -> &std::path::Path {
&self.script_path
}
#[allow(clippy::needless_pass_by_value)]
pub fn apply_to_command(&self, cmd: duct::Expression) -> duct::Expression {
cmd.env("SSH_ASKPASS", &self.script_path)
.env("SSH_ASKPASS_REQUIRE", "force")
.env("DISPLAY", ":0")
}
pub fn cleanup(&self) {
if let Err(e) = std::fs::remove_file(&self.script_path) {
tracing::warn!(
"failed to remove askpass script {}: {}",
self.script_path.display(),
e
);
}
}
fn create_script_in(passphrase: &str, dir: &std::path::Path) -> Result<PathBuf> {
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
let mut passphrase_buf = passphrase.to_string();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid = std::process::id();
let tid = format!("{:?}", std::thread::current().id())
.replace("ThreadId(", "")
.replace(')', "");
let filename = format!("toride-askpass-{pid}-{tid}-{ts}");
#[cfg_attr(unix, allow(unused_mut))]
let mut script_path = dir.join(&filename);
#[cfg(unix)]
{
let escaped = passphrase_buf.replace('\'', "'\\''");
let script_content = format!("#!/bin/sh\necho '{escaped}'\n");
let tmp_path = {
let mut name = filename.clone();
name.push_str(".tmp");
dir.join(&name)
};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o700)
.open(&tmp_path)
.map_err(|e| {
Error::CommandFailed(format!(
"failed to create askpass script {}: {e}",
tmp_path.display()
))
})?;
file.write_all(script_content.as_bytes()).map_err(|e| {
Error::CommandFailed(format!(
"failed to write askpass script {}: {e}",
tmp_path.display()
))
})?;
let _ = file.sync_all();
drop(file);
std::fs::rename(&tmp_path, &script_path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
Error::CommandFailed(format!(
"failed to publish askpass script {}: {e}",
script_path.display()
))
})?;
}
#[cfg(windows)]
{
let bat_path = script_path.with_extension("bat");
let escaped = passphrase_buf
.replace('%', "%%")
.replace('!', "^^!")
.replace('"', "\"\"");
let script_content = format!(
"@echo off\r\n\
setlocal enabledelayedexpansion\r\n\
set \"PASSPHRASE={escaped}\"\r\n\
echo !PASSPHRASE!\r\n"
);
let tmp_path = {
let mut name = filename.clone();
name.push_str(".tmp");
dir.join(&name)
};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
.map_err(|e| {
Error::CommandFailed(format!(
"failed to create askpass script {}: {e}",
tmp_path.display()
))
})?;
file.write_all(script_content.as_bytes()).map_err(|e| {
Error::CommandFailed(format!(
"failed to write askpass script {}: {e}",
tmp_path.display()
))
})?;
let _ = file.sync_all();
drop(file);
std::fs::rename(&tmp_path, &bat_path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
Error::CommandFailed(format!(
"failed to publish askpass script {}: {e}",
bat_path.display()
))
})?;
script_path = bat_path;
}
passphrase_buf.zeroize();
Ok(script_path)
}
}
impl Drop for AskpassHandler {
fn drop(&mut self) {
self.cleanup();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn handler_in_tempdir(passphrase: &str) -> (tempfile::TempDir, AskpassHandler) {
let dir = tempfile::TempDir::new().expect("failed to create temp dir");
let handler = AskpassHandler::new_in_dir(passphrase, dir.path())
.expect("failed to create askpass handler");
(dir, handler)
}
#[test]
fn creates_and_cleans_up_script() {
let (_dir, handler) = handler_in_tempdir("test-passphrase");
assert!(
handler.script_path().exists(),
"askpass script should exist after creation"
);
let path = handler.script_path().to_path_buf();
handler.cleanup();
assert!(
!path.exists(),
"askpass script should be removed after cleanup"
);
}
#[test]
fn drop_removes_script() {
let path;
{
let (_dir, handler) = handler_in_tempdir("drop-test");
path = handler.script_path().to_path_buf();
assert!(path.exists());
}
assert!(!path.exists(), "askpass script should be removed on drop");
}
#[test]
fn script_is_executable() {
let (_dir, handler) = handler_in_tempdir("exec-test");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(handler.script_path())
.unwrap()
.permissions()
.mode();
assert_ne!(mode & 0o100, 0, "script should be owner-executable");
assert_eq!(mode & 0o777, 0o700, "script should have 0o700 permissions");
}
handler.cleanup();
}
#[test]
fn script_outputs_passphrase() {
let (_dir, handler) = handler_in_tempdir("my-secret-pass");
#[cfg(unix)]
{
let output = run_script_retrying_busy(handler.script_path());
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(
stdout.trim(),
"my-secret-pass",
"script should output the passphrase"
);
}
handler.cleanup();
}
#[test]
fn script_with_single_quotes_in_passphrase() {
let (_dir, handler) = handler_in_tempdir("it's a \"test\"");
#[cfg(unix)]
{
let output = run_script_retrying_busy(handler.script_path());
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(
stdout.trim(),
"it's a \"test\"",
"script should handle single quotes in passphrase"
);
}
handler.cleanup();
}
#[test]
fn script_with_empty_passphrase() {
let (_dir, handler) = handler_in_tempdir("");
#[cfg(unix)]
{
let output = run_script_retrying_busy(handler.script_path());
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(
stdout.trim(),
"",
"empty passphrase should produce empty output"
);
}
handler.cleanup();
}
#[cfg(unix)]
fn run_script_retrying_busy(path: &std::path::Path) -> std::process::Output {
let mut backoff = std::time::Duration::from_micros(100);
for attempt in 0..50 {
match std::process::Command::new(path).output() {
Ok(o) => return o,
Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) && attempt < 49 => {
std::thread::sleep(backoff);
backoff = (backoff * 2).min(std::time::Duration::from_millis(5));
}
Err(e) => panic!("failed to run askpass script: {e}"),
}
}
unreachable!("retry loop exhausted without returning or panicking");
}
#[test]
fn apply_to_command_sets_env_vars() {
let (_dir, handler) = handler_in_tempdir("env-test");
let cmd = duct::cmd!("true");
let _configured = handler.apply_to_command(cmd);
handler.cleanup();
}
#[test]
fn cleanup_is_idempotent() {
let dir = tempfile::TempDir::new().expect("failed to create temp dir");
let handler = AskpassHandler::new_in_dir("idempotent-test", dir.path());
let Ok(handler) = handler else {
return;
};
handler.cleanup();
handler.cleanup();
}
}