purple-ssh 3.18.0

Open-source terminal SSH manager that keeps ~/.ssh/config in sync with your cloud infra. Spin up a VM on AWS, GCP, Azure, Hetzner or 12 other cloud providers and it appears in your host list. Destroy it and the entry dims. Search hundreds of hosts, transfer files, manage Docker and Podman over SSH, sign Vault SSH certs. Rust TUI, MIT licensed.
Documentation
// Resolved process environment and filesystem paths, captured once at the
// edge (`launcher::run`) and threaded explicitly from there. Replaces ambient
// `std::env::var` / `dirs::home_dir()` reads scattered through the codebase so
// tests construct an `Env` directly instead of mutating process-global state.
//
// `Env` is immutable after construction, so an `Arc<Env>` crosses thread
// boundaries into worker closures without a lock.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// Home-derived file locations under `~/.purple` and `~/.ssh`. One place that
/// knows the on-disk layout; every consumer asks here instead of joining the
/// home directory itself.
#[derive(Clone)]
pub struct Paths {
    home: PathBuf,
}

impl Paths {
    pub fn new(home: impl Into<PathBuf>) -> Self {
        Self { home: home.into() }
    }

    pub fn home(&self) -> &Path {
        &self.home
    }

    /// `~/.purple`.
    pub fn purple_dir(&self) -> PathBuf {
        self.home.join(".purple")
    }

    /// `~/.purple/preferences`.
    pub fn preferences(&self) -> PathBuf {
        self.purple_dir().join("preferences")
    }

    /// `~/.purple/snippets`.
    pub fn snippets_dir(&self) -> PathBuf {
        self.purple_dir().join("snippets")
    }

    /// `~/.purple/container_cache.jsonl`.
    pub fn container_cache(&self) -> PathBuf {
        self.purple_dir().join("container_cache.jsonl")
    }

    /// `~/.purple/purple.log`.
    pub fn log_file(&self) -> PathBuf {
        self.purple_dir().join("purple.log")
    }

    /// `~/.purple/history.tsv`.
    pub fn history(&self) -> PathBuf {
        self.purple_dir().join("history.tsv")
    }

    /// `~/.purple/last_version_check`.
    pub fn last_version_check(&self) -> PathBuf {
        self.purple_dir().join("last_version_check")
    }

    /// `~/.purple/certs`.
    pub fn certs_dir(&self) -> PathBuf {
        self.purple_dir().join("certs")
    }

    /// `~/.purple/certs/<alias>-cert.pub`.
    pub fn cert_for(&self, alias: &str) -> PathBuf {
        self.certs_dir().join(format!("{alias}-cert.pub"))
    }

    /// `~/.ssh`.
    pub fn ssh_dir(&self) -> PathBuf {
        self.home.join(".ssh")
    }

    /// Askpass retry marker `~/.purple/.askpass_<safe>`. The alias is
    /// sanitised (`/`, `\`, `.` become `_`) to prevent path traversal.
    pub fn askpass_marker(&self, alias: &str) -> PathBuf {
        let safe = alias.replace(['/', '\\', '.'], "_");
        self.purple_dir().join(format!(".askpass_{safe}"))
    }
}

/// The resolved environment for one process run: the home-derived paths plus a
/// snapshot of the process environment variables. Built once via
/// [`Env::from_process`] and passed down by reference (or `Arc`) rather than
/// re-read on demand.
#[derive(Clone)]
pub struct Env {
    paths: Option<Paths>,
    vars: HashMap<String, String>,
    // Test sandbox: owns the temp directory that `paths` points into so it
    // lives exactly as long as the Env (and any `Arc<Env>` clone). Absent from
    // production builds; `tempfile` is a dev-dependency.
    #[cfg(test)]
    _sandbox: Option<std::sync::Arc<tempfile::TempDir>>,
}

impl Env {
    fn new_inner(paths: Option<Paths>, vars: HashMap<String, String>) -> Self {
        Self {
            paths,
            vars,
            #[cfg(test)]
            _sandbox: None,
        }
    }

    /// Capture the real process environment: the home directory and a snapshot
    /// of all environment variables. The single point where production reads
    /// `std::env` and `dirs::home_dir`.
    pub fn from_process() -> Self {
        Self::new_inner(dirs::home_dir().map(Paths::new), std::env::vars().collect())
    }

    /// A test environment rooted at `home` with no environment variables. Add
    /// variables with [`Env::with_var`].
    pub fn for_test(home: impl Into<PathBuf>) -> Self {
        Self::new_inner(Some(Paths::new(home)), HashMap::new())
    }

    /// An environment with neither a home directory nor variables. Models the
    /// rare case where `dirs::home_dir()` returns `None`.
    pub fn empty() -> Self {
        Self::new_inner(None, HashMap::new())
    }

    /// A self-cleaning sandbox rooted at a fresh temp directory, owned by the
    /// Env. Each call is isolated, so parallel tests never share on-disk state
    /// and need no lock. The default for test `App` fixtures.
    #[cfg(test)]
    pub fn sandboxed() -> Self {
        let dir = tempfile::tempdir().expect("create test sandbox tempdir");
        let mut env = Self::for_test(dir.path());
        env._sandbox = Some(std::sync::Arc::new(dir));
        env
    }

    /// Builder: set a variable. Chainable, for test construction.
    #[must_use]
    pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.vars.insert(key.into(), value.into());
        self
    }

    /// Home-derived paths, or `None` when the home directory is unknown.
    pub fn paths(&self) -> Option<&Paths> {
        self.paths.as_ref()
    }

    /// Raw lookup of an arbitrary variable. Used by SSH config `${VAR}`
    /// expansion, which references user-chosen names.
    pub fn var(&self, key: &str) -> Option<&str> {
        self.vars.get(key).map(String::as_str)
    }

    /// `VAULT_ADDR` fallback for Vault SSH address resolution.
    pub fn vault_addr(&self) -> Option<&str> {
        self.var("VAULT_ADDR")
    }

    /// `(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)` when both are set.
    pub fn aws_credentials(&self) -> Option<(&str, &str)> {
        match (
            self.var("AWS_ACCESS_KEY_ID"),
            self.var("AWS_SECRET_ACCESS_KEY"),
        ) {
            (Some(id), Some(secret)) => Some((id, secret)),
            _ => None,
        }
    }

    /// `PURPLE_TOKEN`, the self-invocation auth token.
    pub fn purple_token(&self) -> Option<&str> {
        self.var("PURPLE_TOKEN")
    }

    /// True when `NO_COLOR` is present (any value), per the no-color convention.
    pub fn no_color(&self) -> bool {
        self.vars.contains_key("NO_COLOR")
    }

    /// `COLORTERM`.
    pub fn colorterm(&self) -> Option<&str> {
        self.var("COLORTERM")
    }

    /// `TERM_PROGRAM`.
    pub fn term_program(&self) -> Option<&str> {
        self.var("TERM_PROGRAM")
    }

    /// `TERM`.
    pub fn term(&self) -> Option<&str> {
        self.var("TERM")
    }

    /// True when running inside tmux (`TMUX` is set).
    pub fn in_tmux(&self) -> bool {
        self.vars.contains_key("TMUX")
    }

    /// Proxy-related variable names that are set to a non-empty value, in a
    /// stable order. Drives the startup banner's proxy summary.
    pub fn active_proxy_vars(&self) -> Vec<&'static str> {
        ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
            .into_iter()
            .filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
            .collect()
    }

    /// Build a `Command` for `program` whose environment is exactly this Env's
    /// snapshot. In production the snapshot is the full process environment
    /// captured at startup, so the subprocess sees the same env it would have
    /// inherited. Tests construct an `Env` with only the vars they care about
    /// (e.g. a stub-binary `PATH`), so subprocess resolution and env-dependent
    /// behaviour are controlled without mutating the process-global env (no
    /// `unsafe set_var`, no lock).
    pub fn command(&self, program: &str) -> std::process::Command {
        let mut cmd = std::process::Command::new(program);
        cmd.env_clear();
        cmd.envs(&self.vars);
        cmd
    }
}

// Manual Debug so a stray `{:?}` never dumps secrets (PURPLE_TOKEN, AWS keys,
// VAULT_ADDR). Shows the home directory and the set of variable names only.
impl std::fmt::Debug for Env {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
        names.sort_unstable();
        f.debug_struct("Env")
            .field("home", &self.paths.as_ref().map(Paths::home))
            .field("var_names", &names)
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn paths_derive_under_purple_and_ssh() {
        let p = Paths::new("/home/u");
        assert_eq!(p.purple_dir(), PathBuf::from("/home/u/.purple"));
        assert_eq!(
            p.preferences(),
            PathBuf::from("/home/u/.purple/preferences")
        );
        assert_eq!(p.snippets_dir(), PathBuf::from("/home/u/.purple/snippets"));
        assert_eq!(
            p.container_cache(),
            PathBuf::from("/home/u/.purple/container_cache.jsonl")
        );
        assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
        assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
        assert_eq!(
            p.last_version_check(),
            PathBuf::from("/home/u/.purple/last_version_check")
        );
        assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
    }

    #[test]
    fn cert_for_uses_alias_filename() {
        let p = Paths::new("/home/u");
        assert_eq!(
            p.cert_for("web-1"),
            PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
        );
    }

    #[test]
    fn askpass_marker_sanitises_traversal_chars() {
        let p = Paths::new("/home/u");
        assert_eq!(
            p.askpass_marker("a/b\\c.d"),
            PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
        );
    }

    #[test]
    fn for_test_has_paths_and_no_vars() {
        let env = Env::for_test("/tmp/x");
        assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
        assert_eq!(env.var("ANYTHING"), None);
        assert!(!env.no_color());
    }

    #[test]
    fn empty_has_no_paths() {
        let env = Env::empty();
        assert!(env.paths().is_none());
    }

    #[test]
    fn sandboxed_gives_isolated_existing_dirs() {
        let a = Env::sandboxed();
        let b = Env::sandboxed();
        let pa = a.paths().unwrap().home().to_path_buf();
        let pb = b.paths().unwrap().home().to_path_buf();
        assert_ne!(pa, pb, "each sandbox is a distinct directory");
        assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
        // Writing through the derived paths works (atomic_write creates parents).
        let prefs = a.paths().unwrap().preferences();
        crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
        assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
    }

    #[test]
    fn with_var_sets_typed_accessors() {
        let env = Env::for_test("/tmp/x")
            .with_var("VAULT_ADDR", "https://vault.example:8200")
            .with_var("COLORTERM", "truecolor")
            .with_var("NO_COLOR", "1")
            .with_var("TMUX", "/tmp/tmux-1000/default,1,0");
        assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
        assert_eq!(env.colorterm(), Some("truecolor"));
        assert!(env.no_color());
        assert!(env.in_tmux());
    }

    #[test]
    fn aws_credentials_require_both_keys() {
        let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
        assert_eq!(only_id.aws_credentials(), None);
        let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
        assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
    }

    #[test]
    fn active_proxy_vars_filters_empty_and_orders() {
        let env = Env::for_test("/tmp/x")
            .with_var("HTTPS_PROXY", "http://proxy:3128")
            .with_var("HTTP_PROXY", "")
            .with_var("NO_PROXY", "localhost");
        assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
    }

    #[test]
    fn debug_redacts_secret_values() {
        let env = Env::for_test("/tmp/x")
            .with_var("PURPLE_TOKEN", "super-secret")
            .with_var("VAULT_ADDR", "https://vault.example:8200");
        let rendered = format!("{env:?}");
        assert!(!rendered.contains("super-secret"));
        assert!(!rendered.contains("vault.example"));
        assert!(rendered.contains("PURPLE_TOKEN"));
        assert!(rendered.contains("VAULT_ADDR"));
    }

    #[test]
    fn from_process_captures_home_and_vars() {
        // Smoke test against the real process: home is usually set, and the
        // snapshot is internally consistent with the typed accessors.
        let env = Env::from_process();
        // No assertion on specific vars (CI environments differ); just verify
        // the snapshot mechanism works end to end.
        let _ = env.paths();
        let _ = env.var("PATH");
    }
}