#![cfg(unix)]
use super::common::*;
use std::path::PathBuf;
use std::time::SystemTime;
#[tokio::test]
async fn test_fallback_from_rsync_to_sftp() {
init_test_env().expect("Failed to initialize test environment");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
if !check_rsync() {
tracing::warn!("skipping: local 'rsync' unavailable");
return;
}
if !check_sftp() {
tracing::warn!("skipping: local 'sftp' unavailable");
return;
}
let (_key_dir, key_path) = setup_test_key();
let container = GenericImage::new("ssh-mcp-debian-sshd-norsync", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: None,
key: Some(key_path),
su_password: None,
sudo_password: None,
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: true,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config)
.await
.expect("Failed to create SshMcpServer");
let home_result = server
.test_execute_command(r#"sh -c 'printf %s "$HOME"'"#)
.await
.expect("failed to resolve remote HOME");
let remote_home = extract_text_from_result(&home_result).trim().to_string();
let unique = format!(
"{}-{}-fallback-rsync-sftp",
std::process::id(),
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let local_base = PathBuf::from("target/tmp").join(format!("fallback-rsync-sftp-{unique}"));
std::fs::create_dir_all(&local_base).expect("create local base");
let local_file = local_base.join("hello.txt");
std::fs::write(&local_file, "hello via fallback to sftp\n").expect("write local file");
let local_path_param = local_file.to_string_lossy().to_string();
let remote_file = format!("{}/fallback-rsync-sftp.txt", remote_home);
let resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param,
remote_path: remote_file.clone(),
transport: TransferTransport::Auto,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(
resp.ok,
"transfer with Auto transport should succeed: {:?}",
resp.error
);
assert_eq!(
resp.transport_used,
TransferTransport::Sftp,
"should fallback from rsync to sftp when rsync unavailable on remote"
);
let verify = server
.test_execute_command(&format!(
"sh -c 'cat < {}'",
ssh_mcp::escape_for_shell(&remote_file)
))
.await
.expect("verify remote file");
let verify_text = extract_text_from_result(&verify);
assert!(
verify_text.contains("hello via fallback to sftp"),
"remote file should contain expected content after fallback"
);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&local_base);
}
#[tokio::test]
async fn test_fallback_from_rsync_to_scp() {
init_test_env().expect("Failed to initialize test environment");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
if !check_rsync() {
tracing::warn!("skipping: local 'rsync' unavailable");
return;
}
if !check_scp() {
tracing::warn!("skipping: local 'scp' unavailable");
return;
}
let (_key_dir, key_path) = setup_test_key();
let container = GenericImage::new("ssh-mcp-debian-sshd-norsync", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: None,
key: Some(key_path),
su_password: None,
sudo_password: None,
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: true,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config)
.await
.expect("Failed to create SshMcpServer");
let home_result = server
.test_execute_command(r#"sh -c 'printf %s "$HOME"'"#)
.await
.expect("failed to resolve remote HOME");
let remote_home = extract_text_from_result(&home_result).trim().to_string();
let unique = format!(
"{}-{}-fallback-rsync-scp",
std::process::id(),
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let local_base = PathBuf::from("target/tmp").join(format!("fallback-rsync-scp-{unique}"));
std::fs::create_dir_all(&local_base).expect("create local base");
let local_file = local_base.join("hello.txt");
std::fs::write(&local_file, "hello via fallback to scp\n").expect("write local file");
let local_path_param = local_file.to_string_lossy().to_string();
let remote_file = format!("{}/fallback-rsync-scp.txt", remote_home);
let resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param,
remote_path: remote_file.clone(),
transport: TransferTransport::Scp,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(
resp.ok,
"transfer with Scp transport should succeed: {:?}",
resp.error
);
assert_eq!(
resp.transport_used,
TransferTransport::Scp,
"should use scp as fallback transport"
);
let verify = server
.test_execute_command(&format!(
"sh -c 'cat < {}'",
ssh_mcp::escape_for_shell(&remote_file)
))
.await
.expect("verify remote file");
let verify_text = extract_text_from_result(&verify);
assert!(
verify_text.contains("hello via fallback to scp"),
"remote file should contain expected content after scp transfer"
);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&local_base);
}
#[tokio::test]
async fn test_fallback_from_sftp_to_scp() {
init_test_env().expect("Failed to initialize test environment");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
if !check_sftp() {
tracing::warn!("skipping: local 'sftp' unavailable");
return;
}
if !check_scp() {
tracing::warn!("skipping: local 'scp' unavailable");
return;
}
let (_key_dir, key_path) = setup_test_key();
let container = GenericImage::new("ssh-mcp-debian-sshd", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: None,
key: Some(key_path),
su_password: None,
sudo_password: None,
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: true,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config)
.await
.expect("Failed to create SshMcpServer");
let home_result = server
.test_execute_command(r#"sh -c 'printf %s "$HOME"'"#)
.await
.expect("failed to resolve remote HOME");
let remote_home = extract_text_from_result(&home_result).trim().to_string();
let unique = format!(
"{}-{}-fallback-sftp-scp",
std::process::id(),
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let local_base = PathBuf::from("target/tmp").join(format!("fallback-sftp-scp-{unique}"));
std::fs::create_dir_all(&local_base).expect("create local base");
let local_file = local_base.join("hello.txt");
std::fs::write(&local_file, "hello via sftp fallback to scp\n").expect("write local file");
let local_path_param = local_file.to_string_lossy().to_string();
let remote_file = format!("{}/fallback-sftp-scp.txt", remote_home);
let resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param,
remote_path: remote_file.clone(),
transport: TransferTransport::Scp,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(
resp.ok,
"transfer with Scp transport should succeed: {:?}",
resp.error
);
assert_eq!(
resp.transport_used,
TransferTransport::Scp,
"should use scp as fallback from sftp"
);
let verify = server
.test_execute_command(&format!(
"sh -c 'cat < {}'",
ssh_mcp::escape_for_shell(&remote_file)
))
.await
.expect("verify remote file");
let verify_text = extract_text_from_result(&verify);
assert!(
verify_text.contains("hello via sftp fallback to scp"),
"remote file should contain expected content after scp fallback"
);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&local_base);
}
#[tokio::test]
async fn test_fallback_all_the_way_to_execraw() {
init_test_env().expect("Failed to build test images");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
let (_key_dir, key_path) = setup_test_key();
let container = GenericImage::new("ssh-mcp-debian-sshd-norsync", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: None,
key: Some(key_path),
su_password: None,
sudo_password: None,
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: true,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config)
.await
.expect("Failed to create SshMcpServer");
let home_result = server
.test_execute_command(r#"sh -c 'printf %s "$HOME"'"#)
.await
.expect("failed to resolve remote HOME");
let remote_home = extract_text_from_result(&home_result).trim().to_string();
let unique = format!(
"{}-{}-fallback-chain",
std::process::id(),
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let local_base = PathBuf::from("target/tmp").join(format!("fallback-chain-{unique}"));
std::fs::create_dir_all(&local_base).expect("create local base");
let local_file = local_base.join("hello.txt");
std::fs::write(&local_file, "hello via complete fallback chain\n").expect("write local file");
let local_path_param = local_file.to_string_lossy().to_string();
let remote_file = format!("{}/fallback-chain-test.txt", remote_home);
let resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param,
remote_path: remote_file.clone(),
transport: TransferTransport::ExecRaw,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(
resp.ok,
"transfer with ExecRaw transport should succeed: {:?}",
resp.error
);
assert_eq!(
resp.transport_used,
TransferTransport::ExecRaw,
"should use ExecRaw as final fallback"
);
let verify = server
.test_execute_command(&format!(
"sh -c 'cat < {}'",
ssh_mcp::escape_for_shell(&remote_file)
))
.await
.expect("verify remote file");
let verify_text = extract_text_from_result(&verify);
assert!(
verify_text.contains("hello via complete fallback chain"),
"remote file should contain expected content after fallback chain"
);
let _unique2 = format!(
"{}-{}-fallback-auto",
std::process::id(),
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let local_file2 = local_base.join("auto-fallback.txt");
std::fs::write(&local_file2, "hello via Auto transport fallback\n")
.expect("write local file 2");
let local_path_param2 = local_file2.to_string_lossy().to_string();
let remote_file2 = format!("{}/fallback-auto-test.txt", remote_home);
let resp2 = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param2,
remote_path: remote_file2.clone(),
transport: TransferTransport::Auto,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(
resp2.ok,
"transfer with Auto transport should succeed: {:?}",
resp2.error
);
assert!(
resp2.transport_used == TransferTransport::Sftp
|| resp2.transport_used == TransferTransport::Scp,
"Auto should fallback from rsync to sftp or scp, got {:?}",
resp2.transport_used
);
let verify2 = server
.test_execute_command(&format!(
"sh -c 'cat < {}'",
ssh_mcp::escape_for_shell(&remote_file2)
))
.await
.expect("verify remote file 2");
let verify2_text = extract_text_from_result(&verify2);
assert!(
verify2_text.contains("hello via Auto transport fallback"),
"remote file should contain expected content after Auto transport"
);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&local_base);
}
#[tokio::test]
async fn test_fallback_tries_all_on_generic_error() {
init_test_env().expect("Failed to initialize test environment");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
if !check_sftp() || !check_scp() {
tracing::warn!("skipping: local tools unavailable");
return;
}
let (_key_dir, key_path) = setup_test_key();
let container = GenericImage::new("ssh-mcp-debian-sshd", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: None,
key: Some(key_path),
su_password: None,
sudo_password: None,
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: true,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config)
.await
.expect("Failed to create SshMcpServer");
let unique = format!("{}-fallback-generic-error", std::process::id());
let local_base = PathBuf::from("target/tmp").join(&unique);
std::fs::create_dir_all(&local_base).expect("create local base");
let local_file = local_base.join("hello.txt");
std::fs::write(&local_file, "content\n").expect("write local file");
let remote_file = "/root/secret_file.txt".to_string();
let resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_file.to_string_lossy().to_string(),
remote_path: remote_file,
transport: TransferTransport::Auto,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(!resp.ok, "Transfer should fail due to permission denied");
let error_msg = resp.error.unwrap_or_default();
let tries_sftp = error_msg.contains("Sftp");
let tries_scp = error_msg.contains("Scp");
let tries_rsync = error_msg.contains("Rsync");
let tries_exec_raw = error_msg.contains("ExecRaw");
assert!(
tries_sftp,
"Should try Sftp first. Actual error: {}",
error_msg
);
assert!(
tries_scp,
"Should try Scp after Sftp fails with generic error. Bug: breaks early. Actual error: {}",
error_msg
);
assert!(
tries_rsync,
"Should try Rsync after Scp fails. Actual error: {}",
error_msg
);
assert!(
tries_exec_raw,
"Should try ExecRaw after Rsync fails. Actual error: {}",
error_msg
);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&local_base);
}