use std::path::Path;
use std::process::Command;
pub(crate) const ONESHOT_ALIAS_VAR: &str = "PURPLE_ASKPASS_ONESHOT_ALIAS";
pub(crate) const ONESHOT_SECRET_VAR: &str = "PURPLE_ASKPASS_ONESHOT";
pub(crate) const SINGLE_ATTEMPT_VAR: &str = "PURPLE_ASKPASS_SINGLE_ATTEMPT";
pub(crate) const BATCH_MODE_OPT: &str = "BatchMode=yes";
pub(crate) const SINGLE_PASSWORD_PROMPT_OPT: &str = "NumberOfPasswordPrompts=1";
pub(crate) fn needs_batch_mode(askpass: Option<&str>, session_password: Option<&str>) -> bool {
askpass.is_none() && session_password.is_none()
}
pub(crate) fn configure_auth(
cmd: &mut Command,
alias: &str,
config_path: &Path,
askpass: Option<&str>,
session_password: Option<&str>,
bw_session: Option<&str>,
background: bool,
) {
if background || askpass.is_some() || session_password.is_some() {
configure_ssh_command(cmd, alias, config_path);
}
if background {
cmd.env(SINGLE_ATTEMPT_VAR, "1");
}
if let Some(secret) = session_password {
cmd.env(ONESHOT_ALIAS_VAR, alias)
.env(ONESHOT_SECRET_VAR, secret);
}
if let Some(token) = bw_session {
cmd.env("BW_SESSION", token);
}
}
pub(crate) fn configure_ssh_command(cmd: &mut Command, alias: &str, config_path: &Path) {
let exe = std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().into_owned())
.or_else(|| std::env::args().next())
.unwrap_or_else(|| "purple".to_string());
cmd.env("SSH_ASKPASS", &exe)
.env("SSH_ASKPASS_REQUIRE", "force")
.env("PURPLE_ASKPASS_MODE", "1")
.env("PURPLE_HOST_ALIAS", alias)
.env("PURPLE_CONFIG_PATH", config_path.as_os_str());
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
fn snapshot_envs(cmd: &Command) -> HashMap<OsString, OsString> {
cmd.get_envs()
.filter_map(|(k, v)| v.map(|val| (k.to_os_string(), val.to_os_string())))
.collect()
}
#[test]
fn sets_ssh_askpass_require_to_force() {
let mut cmd = Command::new("ssh");
configure_ssh_command(&mut cmd, "myhost", &PathBuf::from("/tmp/cfg"));
let envs = snapshot_envs(&cmd);
assert_eq!(
envs.get(&OsString::from("SSH_ASKPASS_REQUIRE")),
Some(&OsString::from("force")),
"SSH_ASKPASS_REQUIRE must be 'force' to work in headless ssh sessions"
);
}
#[test]
fn sets_ssh_askpass_to_current_exe() {
let mut cmd = Command::new("ssh");
configure_ssh_command(&mut cmd, "myhost", &PathBuf::from("/tmp/cfg"));
let envs = snapshot_envs(&cmd);
let askpass = envs
.get(&OsString::from("SSH_ASKPASS"))
.expect("SSH_ASKPASS must be set");
assert!(
!askpass.is_empty(),
"SSH_ASKPASS must point at a non-empty path"
);
}
#[test]
fn sets_purple_context_vars() {
let mut cmd = Command::new("ssh");
configure_ssh_command(&mut cmd, "myhost", &PathBuf::from("/tmp/my/ssh_config"));
let envs = snapshot_envs(&cmd);
assert_eq!(
envs.get(&OsString::from("PURPLE_ASKPASS_MODE")),
Some(&OsString::from("1"))
);
assert_eq!(
envs.get(&OsString::from("PURPLE_HOST_ALIAS")),
Some(&OsString::from("myhost"))
);
assert_eq!(
envs.get(&OsString::from("PURPLE_CONFIG_PATH")),
Some(&OsString::from("/tmp/my/ssh_config"))
);
}
#[test]
fn passes_alias_with_spaces_and_slashes_unmodified() {
let mut cmd = Command::new("ssh");
configure_ssh_command(&mut cmd, "my host/with slash", &PathBuf::from("/tmp/cfg"));
let envs = snapshot_envs(&cmd);
assert_eq!(
envs.get(&OsString::from("PURPLE_HOST_ALIAS")),
Some(&OsString::from("my host/with slash"))
);
}
#[test]
fn does_not_set_bw_session() {
let mut cmd = Command::new("ssh");
configure_ssh_command(&mut cmd, "myhost", &PathBuf::from("/tmp/cfg"));
let envs = snapshot_envs(&cmd);
assert!(!envs.contains_key(&OsString::from("BW_SESSION")));
}
#[test]
fn needs_batch_mode_only_without_source_and_session_password() {
assert!(needs_batch_mode(None, None));
assert!(!needs_batch_mode(Some("keychain"), None));
assert!(!needs_batch_mode(None, Some("hunter2")));
assert!(!needs_batch_mode(Some("bw:item"), Some("hunter2")));
}
#[test]
fn a_terminal_run_without_inputs_sets_nothing() {
let mut cmd = Command::new("ssh");
configure_auth(
&mut cmd,
"h",
&PathBuf::from("/tmp/cfg"),
None,
None,
None,
false,
);
assert!(snapshot_envs(&cmd).is_empty());
}
#[test]
fn a_background_run_wires_askpass_even_with_nothing_to_answer_with() {
let mut cmd = Command::new("ssh");
configure_auth(
&mut cmd,
"h",
&PathBuf::from("/tmp/cfg"),
None,
None,
None,
true,
);
let envs = snapshot_envs(&cmd);
assert_eq!(
envs.get(&OsString::from("SSH_ASKPASS_REQUIRE")),
Some(&OsString::from("force")),
"force is what keeps the prompt off the terminal on every hop"
);
assert_eq!(
envs.get(&OsString::from(SINGLE_ATTEMPT_VAR)),
Some(&OsString::from("1"))
);
}
#[test]
fn a_terminal_run_with_a_source_keeps_the_retry_marker() {
let mut cmd = Command::new("ssh");
configure_auth(
&mut cmd,
"h",
&PathBuf::from("/tmp/cfg"),
Some("keychain"),
None,
None,
false,
);
let envs = snapshot_envs(&cmd);
assert_eq!(
envs.get(&OsString::from("SSH_ASKPASS_REQUIRE")),
Some(&OsString::from("force")),
"a configured source still reaches ssh"
);
assert!(
!envs.contains_key(&OsString::from(SINGLE_ATTEMPT_VAR)),
"the single-attempt flag belongs to background runs only"
);
}
#[test]
fn configure_auth_with_source_wires_askpass_only() {
let mut cmd = Command::new("ssh");
configure_auth(
&mut cmd,
"h",
&PathBuf::from("/tmp/cfg"),
Some("keychain"),
None,
None,
true,
);
let envs = snapshot_envs(&cmd);
assert_eq!(
envs.get(&OsString::from("SSH_ASKPASS_REQUIRE")),
Some(&OsString::from("force"))
);
assert!(!envs.contains_key(&OsString::from(ONESHOT_SECRET_VAR)));
assert!(!envs.contains_key(&OsString::from(ONESHOT_ALIAS_VAR)));
}
#[test]
fn configure_auth_with_session_password_wires_askpass_and_oneshot() {
let mut cmd = Command::new("ssh");
configure_auth(
&mut cmd,
"web1",
&PathBuf::from("/tmp/cfg"),
None,
Some("hunter2"),
None,
true,
);
let envs = snapshot_envs(&cmd);
assert_eq!(
envs.get(&OsString::from("SSH_ASKPASS_REQUIRE")),
Some(&OsString::from("force"))
);
assert_eq!(
envs.get(&OsString::from(ONESHOT_ALIAS_VAR)),
Some(&OsString::from("web1"))
);
assert_eq!(
envs.get(&OsString::from(ONESHOT_SECRET_VAR)),
Some(&OsString::from("hunter2"))
);
}
#[test]
fn configure_auth_forwards_bw_session() {
let mut cmd = Command::new("ssh");
configure_auth(
&mut cmd,
"h",
&PathBuf::from("/tmp/cfg"),
Some("bw:item"),
None,
Some("tok"),
true,
);
let envs = snapshot_envs(&cmd);
assert_eq!(
envs.get(&OsString::from("BW_SESSION")),
Some(&OsString::from("tok"))
);
}
#[test]
fn oneshot_secret_never_lands_in_argv() {
let mut cmd = Command::new("ssh");
configure_auth(
&mut cmd,
"h",
&PathBuf::from("/tmp/cfg"),
None,
Some("hunter2"),
None,
true,
);
assert!(cmd.get_args().all(|a| a != "hunter2"));
}
}