msy 0.4.2

Modern musl rsync alternative - Fast, parallel file synchronization
Documentation
use super::config::SshConfig;
use crate::error::{Result, SyncError};
use ssh2::Session;
use std::io::ErrorKind;
use std::net::TcpStream;
use std::time::Duration;

/// SSH connection timeout (default 30 seconds)
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);

/// Establish an SSH connection using the provided configuration
///
/// This function:
/// 1. Establishes a TCP connection to the SSH server
/// 2. Creates an SSH session
/// 3. Performs SSH handshake
/// 4. Authenticates using available methods (keys, agent, password)
pub async fn connect(config: &SshConfig) -> Result<Session> {
    // Establish TCP connection
    let tcp = connect_tcp(&config.hostname, config.port).await?;

    // Clone config data needed for authentication
    let username = config.user.clone();
    let identity_files = config.identity_file.clone();

    // Wrap all sync operations (session creation, handshake, auth) in spawn_blocking
    let session = tokio::task::spawn_blocking(move || {
        // Create SSH session
        let mut session = Session::new().map_err(|e| {
            SyncError::Io(std::io::Error::other(format!(
                "Failed to create SSH session: {}",
                e
            )))
        })?;

        // Keep session blocking for handshake and authentication
        // (we're already in spawn_blocking context)
        session.set_timeout(DEFAULT_TIMEOUT.as_millis() as u32);

        // Set TCP stream
        session.set_tcp_stream(tcp);

        // Perform SSH handshake
        session.handshake().map_err(|e| {
            SyncError::Io(std::io::Error::other(format!(
                "SSH handshake failed: {}",
                e
            )))
        })?;

        // Configure keepalive to prevent connection drops during long transfers
        // Send keepalive every 60 seconds, disconnect after 3 missed responses
        session.set_keepalive(true, 60);

        // Try authentication methods in order of preference:
        // 1. SSH agent (if available)
        // 2. Identity files (keys)
        // 3. Default keys

        // Try SSH agent first
        if let Ok(mut agent) = session.agent()
            && agent.connect().is_ok() && agent.list_identities().is_ok()
                && let Ok(identities) = agent.identities() {
                    for identity in identities {
                        if agent.userauth(&username, &identity).is_ok() {
                            tracing::debug!("Authenticated using SSH agent");
                            return Ok(session);
                        }
                    }
                }

        // Try each identity file
        for identity_file in &identity_files {
            if session
                .userauth_pubkey_file(&username, None, identity_file, None)
                .is_ok()
            {
                tracing::debug!("Authenticated using key: {}", identity_file.display());
                return Ok(session);
            }
        }

        // Try default keys if no identity files specified
        if identity_files.is_empty()
            && let Some(home) = dirs::home_dir() {
                let default_keys = [
                    home.join(".ssh/id_rsa"),
                    home.join(".ssh/id_ed25519"),
                    home.join(".ssh/id_ecdsa"),
                ];

                for key_path in &default_keys {
                    if key_path.exists()
                        && session
                            .userauth_pubkey_file(&username, None, key_path, None)
                            .is_ok()
                    {
                        tracing::debug!("Authenticated using key: {}", key_path.display());
                        return Ok(session);
                    }
                }
            }

        Err(SyncError::Io(std::io::Error::new(
            ErrorKind::PermissionDenied,
            format!("SSH authentication failed for user {}", username),
        )))
    })
    .await
    .map_err(|e| SyncError::Io(std::io::Error::other(e.to_string())))??;

    Ok(session)
}

/// Establish TCP connection to SSH server
async fn connect_tcp(hostname: &str, port: u16) -> Result<TcpStream> {
    let addr = format!("{}:{}", hostname, port);

    tokio::time::timeout(DEFAULT_TIMEOUT, async {
        TcpStream::connect(&addr).map_err(|e| {
            SyncError::Io(std::io::Error::new(
                ErrorKind::ConnectionRefused,
                format!("Failed to connect to {}: {}", addr, e),
            ))
        })
    })
    .await
    .map_err(|_| {
        SyncError::Io(std::io::Error::new(
            ErrorKind::TimedOut,
            format!("Connection to {} timed out", addr),
        ))
    })?
}

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

    #[test]
    fn test_ssh_config_basic() {
        let config = SshConfig {
            hostname: "localhost".to_string(),
            port: 22,
            user: "testuser".to_string(),
            identity_file: vec![PathBuf::from("~/.ssh/id_rsa")],
            proxy_jump: None,
            control_master: false,
            control_path: None,
            control_persist: None,
            compression: false,
        };

        assert_eq!(config.hostname, "localhost");
        assert_eq!(config.port, 22);
        assert_eq!(config.user, "testuser");
    }

    // Note: Actual connection tests require a running SSH server
    // These would be integration tests, not unit tests
}