susshi 0.15.2

A modern terminal-based SSH connection manager with a beautiful Catppuccin TUI
Documentation
//! Exécution des hooks shell `pre_connect` / `post_disconnect`.
//!
//! Chaque hook reçoit les variables d'environnement suivantes :
//!
//! | Variable         | Contenu                              |
//! |------------------|--------------------------------------|
//! | `SUSSHI_SERVER`  | Nom du serveur (label)               |
//! | `SUSSHI_HOST`    | Adresse IP ou hostname               |
//! | `SUSSHI_USER`    | Utilisateur SSH                      |
//! | `SUSSHI_PORT`    | Port SSH                             |
//! | `SUSSHI_MODE`    | Mode de connexion (`Direct`, `Jump`, `Wallix`) |
//!
//! Un code de retour non-zéro est considéré comme un échec.
//! Si le hook dépasse `server.hook_timeout_secs`, il est tué et une erreur est retournée.

use crate::config::ResolvedServer;
use anyhow::{Result, anyhow};
use std::time::{Duration, Instant};

/// Exécute le script `path` avec les variables d'environnement du serveur.
///
/// - `path` vide → `Ok(())` sans rien faire.
/// - Tilde dans `path` est expandé.
/// - Un code de retour non-zéro ou un timeout retourne `Err`.
pub fn run_hook(path: &str, server: &ResolvedServer) -> Result<()> {
    if path.is_empty() {
        return Ok(());
    }

    let expanded = shellexpand::tilde(path).into_owned();
    let mode = format!("{:?}", server.default_mode);

    let mut child = std::process::Command::new(&expanded)
        .env("SUSSHI_SERVER", &server.name)
        .env("SUSSHI_HOST", &server.host)
        .env("SUSSHI_USER", &server.user)
        .env("SUSSHI_PORT", server.port.to_string())
        .env("SUSSHI_MODE", &mode)
        .spawn()
        .map_err(|e| anyhow!("hook {expanded}: impossible de lancer : {e}"))?;

    let timeout = Duration::from_secs(server.hook_timeout_secs);
    let start = Instant::now();

    loop {
        match child.try_wait()? {
            Some(status) if status.success() => return Ok(()),
            Some(status) => {
                return Err(anyhow!(
                    "hook {expanded}: exit code {:?}",
                    status.code().unwrap_or(-1)
                ));
            }
            None if start.elapsed() >= timeout => {
                let _ = child.kill();
                return Err(anyhow!(
                    "hook {expanded}: timeout ({}s)",
                    server.hook_timeout_secs
                ));
            }
            None => std::thread::sleep(Duration::from_millis(50)),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{ConnectionMode, ResolvedServer};

    fn base_server() -> ResolvedServer {
        ResolvedServer {
            namespace: String::new(),
            group_name: String::new(),
            env_name: String::new(),
            name: "srv".into(),
            host: "198.51.100.1".into(),
            user: "admin".into(),
            port: 22,
            ssh_key: String::new(),
            ssh_options: vec![],
            default_mode: ConnectionMode::Direct,
            jump_host: None,
            bastion_host: None,
            bastion_user: None,
            bastion_template: String::new(),
            use_system_ssh_config: false,
            probe_filesystems: vec![],
            tunnels: vec![],
            tags: vec![],
            control_master: false,
            agent_forwarding: false,
            control_path: String::new(),
            control_persist: "10m".to_string(),
            pre_connect_hook: None,
            post_disconnect_hook: None,
            hook_timeout_secs: 5,
            wallix_group: None,
            wallix_account: "default".to_string(),
            wallix_protocol: "SSH".to_string(),
            wallix_auto_select: true,
            wallix_fail_if_menu_match_error: true,
            wallix_selection_timeout_secs: 8,
        }
    }

    #[test]
    fn empty_path_is_noop() {
        let s = base_server();
        assert!(run_hook("", &s).is_ok());
    }

    #[test]
    fn nonexistent_script_returns_err() {
        let s = base_server();
        let result = run_hook("/tmp/__susshi_nonexistent_hook_xyz.sh", &s);
        assert!(result.is_err());
    }

    #[test]
    fn script_exit_zero_is_ok() {
        let s = base_server();
        // `true` est disponible sur tous les systèmes POSIX
        assert!(run_hook("/usr/bin/true", &s).is_ok());
    }

    #[test]
    fn script_exit_nonzero_is_err() {
        let s = base_server();
        assert!(run_hook("/usr/bin/false", &s).is_err());
    }

    #[test]
    fn timeout_kills_slow_script() {
        let mut s = base_server();
        s.hook_timeout_secs = 1;
        // `sleep 5` dépasse le timeout de 1s
        let result = run_hook("/usr/bin/sleep", &s);
        // La commande sleep sans argument retourne une erreur de lancement (pas de timeout)
        // mais le test vérifie surtout que run_hook ne bloque pas indéfiniment.
        // On accepte soit Err (pas d'arg) soit Ok (si sleep accepte "" et bloque).
        // En pratique sleep sans arg échoue immédiatement → Err.
        let _ = result; // on ne plante pas
    }
}