pub use ssh_mcp::transfer::{TransferKind, TransferOperation, TransferParams, TransferTransport};
pub use ssh_mcp::{Config, SshMcpServer};
pub use std::sync::Once;
pub use testcontainers::runners::AsyncRunner;
pub use testcontainers::{GenericImage, ImageExt};
pub use std::sync::Mutex;
pub static IMAGE_BUILD_ONCE: Once = Once::new();
pub static IMAGE_BUILD_RESULT: Mutex<Option<Result<(), String>>> = Mutex::new(None);
pub fn ensure_debian_sshd_image() -> Result<(), String> {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let mut errors = Vec::new();
let check_and_build = |image_name: &str, dockerfile_path: &str| {
let output = std::process::Command::new("docker")
.args(["images", "--format", "{{.Repository}}:{{.Tag}}", image_name])
.output()
.map_err(|e| format!("Failed to check if Docker image {image_name} exists: {e}"))?;
let existing = String::from_utf8_lossy(&output.stdout);
if existing.trim() == image_name {
return Ok(());
}
let output = std::process::Command::new("docker")
.args([
"build",
"-t",
image_name,
"-f",
dockerfile_path,
manifest_dir,
])
.output()
.map_err(|e| format!("Failed to build Docker image {image_name}: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Docker build failed for {image_name}: {stderr}"));
}
Ok(())
};
let dockerfile_path = format!("{}/tests/fixtures/debian-sshd/Dockerfile", manifest_dir);
if let Err(e) = check_and_build("ssh-mcp-debian-sshd:latest", &dockerfile_path) {
errors.push(e);
}
let norsync_dockerfile_path = format!(
"{}/tests/fixtures/debian-sshd-norsync/Dockerfile",
manifest_dir
);
if let Err(e) = check_and_build(
"ssh-mcp-debian-sshd-norsync:latest",
&norsync_dockerfile_path,
) {
errors.push(e);
}
let fish_dockerfile_path = format!(
"{}/tests/fixtures/debian-sshd-fish/Dockerfile",
manifest_dir
);
if let Err(e) = check_and_build("ssh-mcp-debian-sshd-fish:latest", &fish_dockerfile_path) {
errors.push(e);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
pub fn init_test_env() -> Result<(), String> {
IMAGE_BUILD_ONCE.call_once(|| {
let result = ensure_debian_sshd_image();
let mut guard = IMAGE_BUILD_RESULT
.lock()
.expect("IMAGE_BUILD_RESULT poisoned");
*guard = Some(result);
});
let guard = IMAGE_BUILD_RESULT
.lock()
.expect("IMAGE_BUILD_RESULT poisoned");
guard.as_ref().expect("IMAGE_BUILD_RESULT not set").clone()
}
pub fn extract_text_from_result(result: &rmcp::model::CallToolResult) -> String {
result
.content
.iter()
.filter_map(|c| {
c.raw
.as_text()
.map(|text_content| text_content.text.clone())
})
.collect::<Vec<_>>()
.join("\n")
}
pub const TEST_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\n\
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n\
QyNTUxOQAAACCZ7b1U1KOd6jVsDPOFQZFVot4BaNM+2hTy6RiD/Ttc+QAAAJD4/zqo+P86\n\
qAAAAAtzc2gtZWQyNTUxOQAAACCZ7b1U1KOd6jVsDPOFQZFVot4BaNM+2hTy6RiD/Ttc+Q\n\
AAAEDCxgrF63olxn5oZkm+x+wntKjbSB9nWO+mazmilqLU5pntvVTUo53qNWwM84VBkVWi\n\
3gFo0z7aFPLpGIP9O1z5AAAADHNzaC1tY3AtdGVzdAE=\n\
-----END OPENSSH PRIVATE KEY-----\n";
pub const TEST_PUBLIC_KEY: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJntvVTUo53qNWwM84VBkVWi3gFo0z7aFPLpGIP9O1z5 ssh-mcp-test";
pub fn setup_test_key() -> (tempfile::TempDir, std::path::PathBuf) {
let key_dir = tempfile::TempDir::new().expect("tempdir");
let key_path = key_dir.path().join("id_ed25519");
std::fs::write(&key_path, TEST_PRIVATE_KEY).expect("write private key");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&key_path, perms).expect("chmod key");
}
(key_dir, key_path)
}
pub fn check_sftp() -> bool {
std::process::Command::new("sftp")
.arg("--help")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|_| true)
.unwrap_or(false)
}
pub fn check_scp() -> bool {
std::process::Command::new("scp")
.arg("--help")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|_| true)
.unwrap_or(false)
}
pub fn check_rsync() -> bool {
match std::process::Command::new("rsync")
.arg("--version")
.output()
{
Ok(out) if out.status.success() => true,
Ok(out) => {
tracing::warn!(
"local 'rsync' exists but '--version' failed (status={}); treating as unavailable",
out.status
);
false
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
tracing::warn!("failed to spawn local 'rsync --version': {e}; treating as unavailable");
false
}
}
}
pub fn check_openssh_client(bin: &str) -> bool {
match std::process::Command::new(bin).arg("-V").output() {
Ok(out) if out.status.success() => true,
Ok(out) => {
tracing::warn!(
"local '{bin}' exists but '{bin} -V' failed (status={}); treating as unavailable",
out.status
);
false
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
tracing::warn!("failed to spawn local '{bin} -V': {e}; treating as unavailable");
false
}
}
}