mod docker_integration;
pub use docker_integration::common::*;
async fn remote_sha256(server: &SshMcpServer, remote_path: &str) -> String {
let cmd = format!(
"sh -c 'set -eu; set -- $(sha256sum -- \"$1\"); printf %s \"$1\"' sh {}",
ssh_mcp::escape_for_shell(remote_path)
);
let result = server
.test_execute_command(&cmd)
.await
.expect("failed to read remote sha256");
extract_text_from_result(&result).trim().to_string()
}
async fn read_file_ticket(server: &SshMcpServer, remote_path: &str) -> (String, String) {
let result = server
.test_read_file(remote_path, None)
.await
.expect("read-file for ticket extraction failed");
let text = extract_text_from_result(&result);
let json: serde_json::Value =
serde_json::from_str(text.trim()).expect("read-file response should be valid JSON");
let sha256 = json
.get("sha256")
.and_then(|v| v.as_str())
.expect("read-file response must contain sha256")
.to_string();
let ticket = json
.get("read_ticket")
.and_then(|v| v.as_str())
.expect("read-file response must contain read_ticket")
.to_string();
(sha256, ticket)
}
#[tokio::test]
async fn test_mcp_tools_with_docker() {
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();
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;
tracing::info!("SSH container started at {}:{}", host, port);
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: Some("secret".to_string()),
key: None,
su_password: None,
sudo_password: Some("secret".to_string()),
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: false,
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");
tracing::info!("SshMcpServer created successfully");
let exec_result = server
.test_execute_command("whoami")
.await
.expect("exec command failed");
let exec_output = extract_text_from_result(&exec_result);
let exec_output = exec_output.trim();
assert!(
exec_output.contains("test"),
"exec 'whoami' should return 'test', got: '{}'",
exec_output
);
tracing::info!("exec tool verified: whoami returned 'test'");
server
.test_execute_command("printf 'read-file smoke\\n' > /tmp/ssh-mcp-read-file.txt")
.await
.expect("failed to create remote file for read-file test");
let read_file_result = server
.test_read_file("/tmp/ssh-mcp-read-file.txt", None)
.await
.expect("read-file command failed");
let read_file_text = extract_text_from_result(&read_file_result);
let read_file_json: serde_json::Value =
serde_json::from_str(read_file_text.trim()).expect("read-file result should be valid JSON");
assert_eq!(
read_file_json.get("path").and_then(|v| v.as_str()),
Some("/tmp/ssh-mcp-read-file.txt")
);
assert_eq!(
read_file_json.get("content").and_then(|v| v.as_str()),
Some("read-file smoke\n")
);
tracing::info!("read-file tool verified: content returned as JSON");
let missing_file_result = server
.test_read_file("/tmp/ssh-mcp-read-file-missing.txt", None)
.await
.expect("read-file missing-file call failed");
assert!(
missing_file_result.is_error.unwrap_or(false),
"missing file should return an error"
);
let missing_file_text = extract_text_from_result(&missing_file_result);
assert!(
missing_file_text.contains("remote_path does not exist"),
"unexpected missing-file error: {missing_file_text}"
);
let non_regular_result = server
.test_read_file("/tmp", None)
.await
.expect("read-file non-regular call failed");
assert!(
non_regular_result.is_error.unwrap_or(false),
"directory path should return an error"
);
let non_regular_text = extract_text_from_result(&non_regular_result);
assert!(
non_regular_text.contains("remote_path is not a regular file"),
"unexpected non-regular-file error: {non_regular_text}"
);
server
.test_execute_command("head -c 48001 /dev/zero > /tmp/ssh-mcp-read-file-too-large.bin")
.await
.expect("failed to create oversized fixture file");
let oversized_result = server
.test_read_file("/tmp/ssh-mcp-read-file-too-large.bin", None)
.await
.expect("read-file oversized call failed");
assert!(
oversized_result.is_error.unwrap_or(false),
"oversized file should return an error"
);
let oversized_text = extract_text_from_result(&oversized_result);
assert!(
oversized_text.contains(
"Error: remote file exceeds read-file size limit (48000 bytes). Use transfer for large files"
),
"unexpected oversized-file error: {oversized_text}"
);
assert!(
!oversized_text.contains("not valid UTF-8"),
"oversized-file path should fail on size before UTF-8 decode: {oversized_text}"
);
server
.test_execute_command("printf '\\377\\376\\375' > /tmp/ssh-mcp-read-file-binary.bin")
.await
.expect("failed to create non-UTF8 fixture file");
let non_utf8_result = server
.test_read_file("/tmp/ssh-mcp-read-file-binary.bin", None)
.await
.expect("read-file non-UTF8 call failed");
assert!(
non_utf8_result.is_error.unwrap_or(false),
"non-UTF8 file should return an error"
);
let non_utf8_text = extract_text_from_result(&non_utf8_result);
assert!(
non_utf8_text.contains("not valid UTF-8 text"),
"unexpected non-UTF8 error: {non_utf8_text}"
);
let long_read_path = "/tmp/ssh-mcp-read-file-long.txt";
server
.test_execute_command(
r#"sh -c 'set -eu; out=/tmp/ssh-mcp-read-file-long.txt; : > "$out"; i=1; while [ "$i" -le 1200 ]; do printf "line-%04d\n" "$i" >> "$out"; i=$((i+1)); done'"#,
)
.await
.expect("failed to create long read-file fixture");
let preview_result = server
.test_read_file(long_read_path, None)
.await
.expect("read-file preview call failed");
let preview_text = extract_text_from_result(&preview_result);
let preview_json: serde_json::Value = serde_json::from_str(preview_text.trim())
.expect("read-file preview response should be valid JSON");
assert_eq!(
preview_json.get("path").and_then(|v| v.as_str()),
Some(long_read_path)
);
assert_eq!(
preview_json.get("mode").and_then(|v| v.as_str()),
Some("preview")
);
assert_eq!(
preview_json.get("returned_lines").and_then(|v| v.as_u64()),
Some(800)
);
assert_eq!(
preview_json.get("truncated").and_then(|v| v.as_bool()),
Some(true)
);
let preview_hint = preview_json
.get("hint")
.and_then(|v| v.as_str())
.expect("preview response should include hint");
assert!(
preview_hint.contains("mode=\"full\""),
"preview hint should include full-read guidance: {preview_hint}"
);
let preview_content = preview_json
.get("content")
.and_then(|v| v.as_str())
.expect("preview response should include content");
assert_eq!(preview_content.lines().count(), 800);
assert!(preview_content.starts_with("line-0001\n"));
assert!(preview_content.ends_with("line-0800\n"));
let preview_tokens = preview_json
.get("approx_tokens_returned")
.and_then(|v| v.as_u64())
.expect("preview response should include approx_tokens_returned");
let preview_total_tokens = preview_json
.get("approx_tokens_total_estimate")
.and_then(|v| v.as_u64())
.expect("preview response should include approx_tokens_total_estimate");
assert!(preview_tokens > 0);
assert!(preview_total_tokens > preview_tokens);
let head_result = server
.test_read_file_with_options(
long_read_path,
ssh_mcp::tools::ReadFileMode::Head,
Some(5),
None,
)
.await
.expect("read-file head call failed");
let head_text = extract_text_from_result(&head_result);
let head_json: serde_json::Value =
serde_json::from_str(head_text.trim()).expect("head response should be valid JSON");
assert_eq!(head_json.get("mode").and_then(|v| v.as_str()), Some("head"));
assert_eq!(
head_json.get("returned_lines").and_then(|v| v.as_u64()),
Some(5)
);
assert_eq!(
head_json.get("content").and_then(|v| v.as_str()),
Some("line-0001\nline-0002\nline-0003\nline-0004\nline-0005\n")
);
let tail_result = server
.test_read_file_with_options(
long_read_path,
ssh_mcp::tools::ReadFileMode::Tail,
Some(4),
None,
)
.await
.expect("read-file tail call failed");
let tail_text = extract_text_from_result(&tail_result);
let tail_json: serde_json::Value =
serde_json::from_str(tail_text.trim()).expect("tail response should be valid JSON");
assert_eq!(tail_json.get("mode").and_then(|v| v.as_str()), Some("tail"));
assert_eq!(
tail_json.get("returned_lines").and_then(|v| v.as_u64()),
Some(4)
);
assert_eq!(
tail_json.get("content").and_then(|v| v.as_str()),
Some("line-1197\nline-1198\nline-1199\nline-1200\n")
);
let full_result = server
.test_read_file_with_options(
long_read_path,
ssh_mcp::tools::ReadFileMode::Full,
None,
None,
)
.await
.expect("read-file full call failed");
let full_text = extract_text_from_result(&full_result);
let full_json: serde_json::Value =
serde_json::from_str(full_text.trim()).expect("full response should be valid JSON");
assert_eq!(full_json.get("mode").and_then(|v| v.as_str()), Some("full"));
assert_eq!(
full_json.get("returned_lines").and_then(|v| v.as_u64()),
Some(1200)
);
assert_eq!(
full_json.get("truncated").and_then(|v| v.as_bool()),
Some(false)
);
assert!(full_json.get("hint").is_none());
let full_content = full_json
.get("content")
.and_then(|v| v.as_str())
.expect("full response should include content");
assert_eq!(full_content.lines().count(), 1200);
let full_tokens = full_json
.get("approx_tokens_returned")
.and_then(|v| v.as_u64())
.expect("full response should include approx_tokens_returned");
let full_total_tokens = full_json
.get("approx_tokens_total_estimate")
.and_then(|v| v.as_u64())
.expect("full response should include approx_tokens_total_estimate");
assert!(full_tokens > 0);
assert_eq!(full_tokens, full_total_tokens);
let huge_path = "/tmp/ssh-mcp-read-file-huge.txt";
server
.test_execute_command(
r#"sh -c 'set -eu; out=/tmp/ssh-mcp-read-file-huge.txt; : > "$out"; i=1; while [ "$i" -le 200000 ]; do printf "huge-line-%06d\n" "$i" >> "$out"; i=$((i+1)); done'"#,
)
.await
.expect("failed to create huge read-file fixture");
let huge_preview_result = server
.test_read_file(huge_path, None)
.await
.expect("read-file preview on huge file failed");
let huge_preview_text = extract_text_from_result(&huge_preview_result);
let huge_preview_json: serde_json::Value = serde_json::from_str(huge_preview_text.trim())
.expect("huge preview response should be valid JSON");
assert_eq!(
huge_preview_json.get("mode").and_then(|v| v.as_str()),
Some("preview")
);
assert_eq!(
huge_preview_json
.get("returned_lines")
.and_then(|v| v.as_u64()),
Some(800)
);
assert_eq!(
huge_preview_json.get("truncated").and_then(|v| v.as_bool()),
Some(true)
);
let huge_preview_content = huge_preview_json
.get("content")
.and_then(|v| v.as_str())
.expect("huge preview should include content");
assert!(huge_preview_content.starts_with("huge-line-000001\n"));
assert!(huge_preview_content.ends_with("huge-line-000800\n"));
let huge_preview_returned = huge_preview_json
.get("approx_tokens_returned")
.and_then(|v| v.as_u64())
.expect("huge preview should include approx_tokens_returned");
let huge_preview_total = huge_preview_json
.get("approx_tokens_total_estimate")
.and_then(|v| v.as_u64())
.expect("huge preview should include approx_tokens_total_estimate");
assert!(
huge_preview_total > huge_preview_returned,
"total token estimate must exceed returned for truncated preview"
);
let huge_head_result = server
.test_read_file_with_options(huge_path, ssh_mcp::tools::ReadFileMode::Head, Some(5), None)
.await
.expect("read-file head on huge file failed");
let huge_head_json: serde_json::Value =
serde_json::from_str(extract_text_from_result(&huge_head_result).trim())
.expect("huge head response should be valid JSON");
assert_eq!(
huge_head_json
.get("returned_lines")
.and_then(|v| v.as_u64()),
Some(5)
);
assert_eq!(
huge_head_json.get("content").and_then(|v| v.as_str()),
Some(
"huge-line-000001\nhuge-line-000002\nhuge-line-000003\nhuge-line-000004\nhuge-line-000005\n"
)
);
let huge_tail_result = server
.test_read_file_with_options(huge_path, ssh_mcp::tools::ReadFileMode::Tail, Some(4), None)
.await
.expect("read-file tail on huge file failed");
let huge_tail_json: serde_json::Value =
serde_json::from_str(extract_text_from_result(&huge_tail_result).trim())
.expect("huge tail response should be valid JSON");
assert_eq!(
huge_tail_json
.get("returned_lines")
.and_then(|v| v.as_u64()),
Some(4)
);
assert_eq!(
huge_tail_json.get("content").and_then(|v| v.as_str()),
Some("huge-line-199997\nhuge-line-199998\nhuge-line-199999\nhuge-line-200000\n")
);
let huge_full_result = server
.test_read_file_with_options(huge_path, ssh_mcp::tools::ReadFileMode::Full, None, None)
.await
.expect("read-file full on huge file failed");
let huge_full_text = extract_text_from_result(&huge_full_result);
assert!(
huge_full_result.is_error.unwrap_or(false),
"full read of oversized file should error"
);
assert!(
huge_full_text.contains("exceeds read-file size limit"),
"oversized full read should mention size limit: {huge_full_text}"
);
server
.test_execute_command(&format!(
"rm -f -- {}",
ssh_mcp::escape_for_shell(huge_path)
))
.await
.expect("failed to clean up huge file");
let create_dir = "/tmp/ssh-mcp-apply-create";
let create_path = "/tmp/ssh-mcp-apply-create/new.txt";
server
.test_execute_command(&format!(
"sh -c 'set -eu; rm -rf -- {dir}; mkdir -p -- {dir}; rm -f -- {file}'",
dir = ssh_mcp::escape_for_shell(create_dir),
file = ssh_mcp::escape_for_shell(create_path),
))
.await
.expect("failed to prepare missing-file create fixture");
let create_missing_result = server
.test_write_file(create_path, "created\n", None, None, Some(30_000))
.await
.expect("write-file create-missing-file call failed");
assert!(
!create_missing_result.is_error.unwrap_or(false),
"missing file with existing parent should be created"
);
let create_missing_text = extract_text_from_result(&create_missing_result);
let create_missing_json: serde_json::Value = serde_json::from_str(create_missing_text.trim())
.expect("create-missing-file response should be valid JSON");
assert_eq!(
create_missing_json.get("path").and_then(|v| v.as_str()),
Some(create_path)
);
assert_eq!(
create_missing_json
.get("bytes_written")
.and_then(|v| v.as_u64()),
Some(8)
);
assert_eq!(
create_missing_json.get("changed").and_then(|v| v.as_bool()),
Some(true)
);
let create_missing_read = server
.test_read_file(create_path, None)
.await
.expect("failed to read create-missing-file result");
let create_missing_read_text = extract_text_from_result(&create_missing_read);
let create_missing_read_json: serde_json::Value =
serde_json::from_str(create_missing_read_text.trim())
.expect("create-missing read-file response should be valid JSON");
assert_eq!(
create_missing_read_json
.get("content")
.and_then(|v| v.as_str()),
Some("created\n")
);
let create_blank_placeholder_path = "/tmp/ssh-mcp-apply-create/blank-placeholder.txt";
server
.test_execute_command(&format!(
"sh -c 'set -eu; rm -f -- {}'",
ssh_mcp::escape_for_shell(create_blank_placeholder_path)
))
.await
.expect("failed to reset blank-placeholder fixture");
let create_blank_placeholder_result = server
.test_write_file(
create_blank_placeholder_path,
"created-via-blank-placeholders\n",
Some(""),
Some(""),
Some(30_000),
)
.await
.expect("write-file blank-placeholder create call failed");
assert!(
!create_blank_placeholder_result.is_error.unwrap_or(false),
"blank placeholder strings should be treated as absent on missing-file create"
);
let create_blank_placeholder_read = server
.test_read_file(create_blank_placeholder_path, None)
.await
.expect("failed to read blank-placeholder create result");
let create_blank_placeholder_text = extract_text_from_result(&create_blank_placeholder_read);
let create_blank_placeholder_json: serde_json::Value =
serde_json::from_str(create_blank_placeholder_text.trim())
.expect("blank-placeholder create read-file response should be valid JSON");
assert_eq!(
create_blank_placeholder_json
.get("content")
.and_then(|v| v.as_str()),
Some("created-via-blank-placeholders\n")
);
let parent_missing_root = "/tmp/ssh-mcp-apply-parent-missing";
let parent_missing_path = "/tmp/ssh-mcp-apply-parent-missing/nested/new.txt";
server
.test_execute_command(&format!(
"sh -c 'set -eu; rm -rf -- {}'",
ssh_mcp::escape_for_shell(parent_missing_root)
))
.await
.expect("failed to remove parent-missing fixture root");
let parent_missing_result = server
.test_write_file(parent_missing_path, "will-fail\n", None, None, Some(30_000))
.await
.expect("write-file parent-missing call failed");
assert!(
parent_missing_result.is_error.unwrap_or(false),
"missing parent should return an error"
);
let parent_missing_text = extract_text_from_result(&parent_missing_result);
assert!(
parent_missing_text.contains("remote parent directory does not exist"),
"unexpected parent-missing error: {parent_missing_text}"
);
let missing_conflict_path = "/tmp/ssh-mcp-apply-create/missing-conflict.txt";
server
.test_execute_command(&format!(
"sh -c 'set -eu; rm -f -- {}'",
ssh_mcp::escape_for_shell(missing_conflict_path)
))
.await
.expect("failed to reset missing-conflict fixture file");
let missing_conflict_result = server
.test_write_file(
missing_conflict_path,
"must-not-create\n",
Some("1111111111111111111111111111111111111111111111111111111111111111"),
None,
Some(30_000),
)
.await
.expect("write-file missing+expected conflict call failed");
assert!(
missing_conflict_result.is_error.unwrap_or(false),
"missing file with expected_sha256 should conflict"
);
let missing_conflict_text = extract_text_from_result(&missing_conflict_result);
let missing_conflict_json: serde_json::Value =
serde_json::from_str(missing_conflict_text.trim())
.expect("missing+expected conflict response should be valid JSON");
assert_eq!(
missing_conflict_json.get("error").and_then(|v| v.as_str()),
Some("conflict")
);
assert_eq!(
missing_conflict_json.get("path").and_then(|v| v.as_str()),
Some(missing_conflict_path)
);
assert_eq!(
missing_conflict_json
.get("expected_sha256")
.and_then(|v| v.as_str()),
Some("1111111111111111111111111111111111111111111111111111111111111111")
);
assert_eq!(
missing_conflict_json
.get("actual_sha256")
.and_then(|v| v.as_str()),
Some("0000000000000000000000000000000000000000000000000000000000000000")
);
let missing_conflict_read = server
.test_read_file(missing_conflict_path, None)
.await
.expect("read-file for missing+expected conflict path failed");
assert!(
missing_conflict_read.is_error.unwrap_or(false),
"conflict path should remain missing"
);
let missing_conflict_read_text = extract_text_from_result(&missing_conflict_read);
assert!(
missing_conflict_read_text.contains("remote_path does not exist"),
"missing+expected conflict should not create destination: {missing_conflict_read_text}"
);
let partial_path = "/tmp/ssh-mcp-replace-in-file-partial.txt";
server
.test_execute_command(&format!(
"printf 'alpha beta\\n' > {}",
ssh_mcp::escape_for_shell(partial_path)
))
.await
.expect("failed to create replace-in-file fixture file");
let partial_single_result = server
.test_replace_in_file(partial_path, "beta", "gamma", false, None, Some(30_000))
.await
.expect("replace-in-file single replacement call failed");
assert!(
!partial_single_result.is_error.unwrap_or(false),
"partial single replacement should return success"
);
let partial_single_read = server
.test_read_file(partial_path, None)
.await
.expect("failed to read file after partial single replacement");
let partial_single_read_text = extract_text_from_result(&partial_single_read);
let partial_single_read_json: serde_json::Value =
serde_json::from_str(partial_single_read_text.trim())
.expect("partial single replacement read-file response should be valid JSON");
assert_eq!(
partial_single_read_json
.get("content")
.and_then(|v| v.as_str()),
Some("alpha gamma\n")
);
server
.test_execute_command(&format!(
"printf 'x x x\\n' > {}",
ssh_mcp::escape_for_shell(partial_path)
))
.await
.expect("failed to reset partial fixture for replace_all");
let partial_replace_all_result = server
.test_replace_in_file(partial_path, "x", "y", true, None, Some(30_000))
.await
.expect("replace-in-file replace_all call failed");
assert!(
!partial_replace_all_result.is_error.unwrap_or(false),
"partial replace_all should return success"
);
let partial_replace_all_read = server
.test_read_file(partial_path, None)
.await
.expect("failed to read file after partial replace_all");
let partial_replace_all_read_text = extract_text_from_result(&partial_replace_all_read);
let partial_replace_all_read_json: serde_json::Value =
serde_json::from_str(partial_replace_all_read_text.trim())
.expect("partial replace_all read-file response should be valid JSON");
assert_eq!(
partial_replace_all_read_json
.get("content")
.and_then(|v| v.as_str()),
Some("y y y\n")
);
let partial_not_found_result = server
.test_replace_in_file(
partial_path,
"missing-token",
"z",
false,
None,
Some(30_000),
)
.await
.expect("replace-in-file not-found call failed");
assert!(
partial_not_found_result.is_error.unwrap_or(false),
"partial replacement with missing old_text should return an error"
);
let partial_not_found_text = extract_text_from_result(&partial_not_found_result);
assert!(
partial_not_found_text.contains("old_text was not found"),
"unexpected partial not-found error: {partial_not_found_text}"
);
server
.test_execute_command(&format!(
"printf 'dup dup\\n' > {}",
ssh_mcp::escape_for_shell(partial_path)
))
.await
.expect("failed to reset partial fixture for ambiguity test");
let partial_ambiguous_result = server
.test_replace_in_file(partial_path, "dup", "solo", false, None, Some(30_000))
.await
.expect("replace-in-file ambiguous call failed");
assert!(
partial_ambiguous_result.is_error.unwrap_or(false),
"partial replacement with multiple matches and replace_all=false should error"
);
let partial_ambiguous_text = extract_text_from_result(&partial_ambiguous_result);
assert!(
partial_ambiguous_text.contains("pass match_index"),
"unexpected partial ambiguity error: {partial_ambiguous_text}"
);
server
.test_execute_command(&format!(
"printf 'dup dup dup\\n' > {}",
ssh_mcp::escape_for_shell(partial_path)
))
.await
.expect("failed to reset partial fixture for match_index test");
let partial_match_index_result = server
.test_replace_in_file_with_params(ssh_mcp::tools::ReplaceInFileParams {
remote_path: partial_path.to_string(),
old_text: "dup".to_string(),
new_text: "solo".to_string(),
scope_text: None,
replace_all: Some(false),
match_index: Some(2),
dry_run: None,
expected_sha256: None,
timeout_ms: Some(30_000),
})
.await
.expect("replace-in-file match_index call failed");
assert!(
!partial_match_index_result.is_error.unwrap_or(false),
"match_index replacement should return success"
);
let partial_match_index_read = server
.test_read_file(partial_path, None)
.await
.expect("failed to read file after match_index replacement");
let partial_match_index_text = extract_text_from_result(&partial_match_index_read);
let partial_match_index_json: serde_json::Value =
serde_json::from_str(partial_match_index_text.trim())
.expect("match_index read-file response should be valid JSON");
assert_eq!(
partial_match_index_json
.get("content")
.and_then(|v| v.as_str()),
Some("dup solo dup\n")
);
server
.test_execute_command(&format!(
"printf 'dup dup\\n' > {}",
ssh_mcp::escape_for_shell(partial_path)
))
.await
.expect("failed to reset partial fixture for dry_run test");
let partial_dry_run_result = server
.test_replace_in_file_with_params(ssh_mcp::tools::ReplaceInFileParams {
remote_path: partial_path.to_string(),
old_text: "dup".to_string(),
new_text: "solo".to_string(),
scope_text: None,
replace_all: Some(false),
match_index: Some(2),
dry_run: Some(true),
expected_sha256: None,
timeout_ms: Some(30_000),
})
.await
.expect("replace-in-file dry_run call failed");
assert!(
!partial_dry_run_result.is_error.unwrap_or(false),
"replace-in-file dry_run should return success"
);
let partial_dry_run_text = extract_text_from_result(&partial_dry_run_result);
let partial_dry_run_json: serde_json::Value = serde_json::from_str(partial_dry_run_text.trim())
.expect("replace-in-file dry_run response should be valid JSON");
assert_eq!(
partial_dry_run_json
.get("dry_run")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
partial_dry_run_json
.get("match_count")
.and_then(|v| v.as_u64()),
Some(2)
);
assert_eq!(
partial_dry_run_json.get("selected_match_indices"),
Some(&serde_json::json!([2]))
);
assert!(
partial_dry_run_json
.get("diff")
.and_then(|v| v.as_str())
.is_some_and(|diff| diff.contains("solo")),
"replace-in-file dry_run should include diff preview: {partial_dry_run_text}"
);
let partial_dry_run_read = server
.test_read_file(partial_path, None)
.await
.expect("failed to read file after replace-in-file dry_run");
let partial_dry_run_read_text = extract_text_from_result(&partial_dry_run_read);
let partial_dry_run_read_json: serde_json::Value =
serde_json::from_str(partial_dry_run_read_text.trim())
.expect("replace-in-file dry_run follow-up read should be valid JSON");
assert_eq!(
partial_dry_run_read_json
.get("content")
.and_then(|v| v.as_str()),
Some("dup dup\n")
);
server
.test_execute_command(&format!(
"printf 'server {{\\n listen 80;\\n server_name app-a;\\n}}\\n\\nserver {{\\n listen 80;\\n server_name app-b;\\n}}\\n' > {}",
ssh_mcp::escape_for_shell(partial_path)
))
.await
.expect("failed to reset partial fixture for scope_text test");
let scoped_block = "server {\n listen 80;\n server_name app-b;\n}\n";
let partial_scoped_result = server
.test_replace_in_file_with_params(ssh_mcp::tools::ReplaceInFileParams {
remote_path: partial_path.to_string(),
old_text: "listen 80;".to_string(),
new_text: "listen 8080;".to_string(),
scope_text: Some(scoped_block.to_string()),
replace_all: Some(false),
match_index: None,
dry_run: None,
expected_sha256: None,
timeout_ms: Some(30_000),
})
.await
.expect("replace-in-file scope_text call failed");
assert!(
!partial_scoped_result.is_error.unwrap_or(false),
"scope_text replacement should return success"
);
let partial_scoped_read = server
.test_read_file(partial_path, None)
.await
.expect("failed to read file after scoped replacement");
let partial_scoped_read_text = extract_text_from_result(&partial_scoped_read);
let partial_scoped_read_json: serde_json::Value =
serde_json::from_str(partial_scoped_read_text.trim())
.expect("scoped replacement read-file response should be valid JSON");
assert_eq!(
partial_scoped_read_json
.get("content")
.and_then(|v| v.as_str()),
Some(
"server {\n listen 80;\n server_name app-a;\n}\n\nserver {\n listen 8080;\n server_name app-b;\n}\n"
)
);
server
.test_execute_command(&format!(
"printf 'server {{\\n listen 80;\\n server_name app-a;\\n}}\\n\\nserver {{\\n listen 80;\\n server_name app-b;\\n}}\\n' > {}",
ssh_mcp::escape_for_shell(partial_path)
))
.await
.expect("failed to reset partial fixture for scope_text dry_run test");
let partial_scoped_dry_run_result = server
.test_replace_in_file_with_params(ssh_mcp::tools::ReplaceInFileParams {
remote_path: partial_path.to_string(),
old_text: "listen 80;".to_string(),
new_text: "listen 8080;".to_string(),
scope_text: Some(scoped_block.to_string()),
replace_all: Some(false),
match_index: None,
dry_run: Some(true),
expected_sha256: None,
timeout_ms: Some(30_000),
})
.await
.expect("replace-in-file scope_text dry_run call failed");
assert!(
!partial_scoped_dry_run_result.is_error.unwrap_or(false),
"scope_text dry_run should return success"
);
let partial_scoped_dry_run_text = extract_text_from_result(&partial_scoped_dry_run_result);
let partial_scoped_dry_run_json: serde_json::Value =
serde_json::from_str(partial_scoped_dry_run_text.trim())
.expect("scope_text dry_run response should be valid JSON");
assert_eq!(
partial_scoped_dry_run_json
.get("dry_run")
.and_then(|v| v.as_bool()),
Some(true)
);
assert!(
partial_scoped_dry_run_json
.get("diff")
.and_then(|v| v.as_str())
.is_some_and(|diff| diff.contains("app-b") && diff.contains("listen 8080;")),
"scope_text dry_run should include scoped diff preview: {partial_scoped_dry_run_text}"
);
let partial_scoped_dry_run_read = server
.test_read_file(partial_path, None)
.await
.expect("failed to read file after scoped dry_run");
let partial_scoped_dry_run_read_text = extract_text_from_result(&partial_scoped_dry_run_read);
let partial_scoped_dry_run_read_json: serde_json::Value =
serde_json::from_str(partial_scoped_dry_run_read_text.trim())
.expect("scoped dry_run follow-up read should be valid JSON");
assert_eq!(
partial_scoped_dry_run_read_json
.get("content")
.and_then(|v| v.as_str()),
Some(
"server {\n listen 80;\n server_name app-a;\n}\n\nserver {\n listen 80;\n server_name app-b;\n}\n"
)
);
let partial_missing_scope_result = server
.test_replace_in_file_with_params(ssh_mcp::tools::ReplaceInFileParams {
remote_path: partial_path.to_string(),
old_text: "listen 80;".to_string(),
new_text: "listen 8080;".to_string(),
scope_text: Some("server {\n listen 80;\n server_name app-z;\n}\n".to_string()),
replace_all: Some(false),
match_index: None,
dry_run: None,
expected_sha256: None,
timeout_ms: Some(30_000),
})
.await
.expect("replace-in-file missing scope_text call failed");
assert!(
partial_missing_scope_result.is_error.unwrap_or(false),
"missing scope_text should return an error"
);
let partial_missing_scope_text = extract_text_from_result(&partial_missing_scope_result);
assert!(
partial_missing_scope_text.contains("scope_text was not found"),
"unexpected missing scope_text error: {partial_missing_scope_text}"
);
let partial_ambiguous_scope_result = server
.test_replace_in_file_with_params(ssh_mcp::tools::ReplaceInFileParams {
remote_path: partial_path.to_string(),
old_text: "listen 80;".to_string(),
new_text: "listen 8080;".to_string(),
scope_text: Some("listen 80;".to_string()),
replace_all: Some(false),
match_index: None,
dry_run: None,
expected_sha256: None,
timeout_ms: Some(30_000),
})
.await
.expect("replace-in-file ambiguous scope_text call failed");
assert!(
partial_ambiguous_scope_result.is_error.unwrap_or(false),
"ambiguous scope_text should return an error"
);
let partial_ambiguous_scope_text = extract_text_from_result(&partial_ambiguous_scope_result);
assert!(
partial_ambiguous_scope_text.contains("scope_text matched 2 times"),
"unexpected ambiguous scope_text error: {partial_ambiguous_scope_text}"
);
let partial_missing_path = "/tmp/ssh-mcp-replace-in-file-partial-missing.txt";
server
.test_execute_command(&format!(
"sh -c 'set -eu; rm -f -- {}'",
ssh_mcp::escape_for_shell(partial_missing_path)
))
.await
.expect("failed to reset missing partial fixture file");
let partial_missing_result = server
.test_replace_in_file(partial_missing_path, "a", "b", false, None, Some(30_000))
.await
.expect("replace-in-file missing-file call failed");
assert!(
partial_missing_result.is_error.unwrap_or(false),
"partial replacement on missing file should return an error"
);
let partial_missing_text = extract_text_from_result(&partial_missing_result);
assert!(
partial_missing_text.contains("remote_path does not exist"),
"unexpected partial missing-file error: {partial_missing_text}"
);
let partial_missing_read = server
.test_read_file(partial_missing_path, None)
.await
.expect("read-file for partial missing-file path failed");
assert!(
partial_missing_read.is_error.unwrap_or(false),
"partial mode should not create missing destination"
);
let invalid_empty_old_text = server
.test_replace_in_file_with_params(ssh_mcp::tools::ReplaceInFileParams {
remote_path: partial_path.to_string(),
old_text: String::new(),
new_text: "replacement".to_string(),
scope_text: None,
replace_all: Some(false),
match_index: None,
dry_run: None,
expected_sha256: None,
timeout_ms: Some(30_000),
})
.await
.expect_err("partial old_text='' should fail with invalid_params");
assert!(
invalid_empty_old_text
.to_string()
.contains("old_text must not be empty in replace-in-file"),
"unexpected invalid_params error for empty old_text: {invalid_empty_old_text}"
);
let invalid_empty_scope_text = server
.test_replace_in_file_with_params(ssh_mcp::tools::ReplaceInFileParams {
remote_path: partial_path.to_string(),
old_text: "dup".to_string(),
new_text: "solo".to_string(),
scope_text: Some(String::new()),
replace_all: Some(false),
match_index: None,
dry_run: None,
expected_sha256: None,
timeout_ms: Some(30_000),
})
.await
.expect_err("partial scope_text='' should fail with invalid_params");
assert!(
invalid_empty_scope_text
.to_string()
.contains("scope_text must not be empty in replace-in-file"),
"unexpected invalid_params error for empty scope_text: {invalid_empty_scope_text}"
);
let partial_delete_race_path = "/tmp/ssh-mcp-replace-in-file-partial-race-delete.txt";
server
.test_execute_command(&format!(
"printf 'race delete token\\n' > {}",
ssh_mcp::escape_for_shell(partial_delete_race_path)
))
.await
.expect("failed to create partial delete-race fixture file");
let partial_delete_race_result = server
.test_replace_in_file_delete_before_write(
partial_delete_race_path,
"token",
"updated",
false,
None,
Some(30_000),
)
.await
.expect("replace-in-file delete-race call failed");
assert!(
partial_delete_race_result.is_error.unwrap_or(false),
"partial delete race should return conflict-like error"
);
let partial_delete_race_text = extract_text_from_result(&partial_delete_race_result);
let partial_delete_race_json: serde_json::Value =
serde_json::from_str(partial_delete_race_text.trim())
.expect("partial delete-race response should be valid JSON conflict");
assert_eq!(
partial_delete_race_json
.get("error")
.and_then(|v| v.as_str()),
Some("conflict")
);
assert_eq!(
partial_delete_race_json
.get("actual_sha256")
.and_then(|v| v.as_str()),
Some("0000000000000000000000000000000000000000000000000000000000000000")
);
let partial_delete_race_read = server
.test_read_file(partial_delete_race_path, None)
.await
.expect("read-file for partial delete-race path failed");
assert!(
partial_delete_race_read.is_error.unwrap_or(false),
"partial delete race should not recreate destination"
);
let partial_delete_race_read_text = extract_text_from_result(&partial_delete_race_read);
assert!(
partial_delete_race_read_text.contains("remote_path does not exist"),
"partial delete race should keep destination missing: {partial_delete_race_read_text}"
);
let partial_mutate_race_path = "/tmp/ssh-mcp-replace-in-file-partial-race-mutate.txt";
server
.test_execute_command(&format!(
"printf 'race mutate token\\n' > {}",
ssh_mcp::escape_for_shell(partial_mutate_race_path)
))
.await
.expect("failed to create partial mutate-race fixture file");
let partial_mutate_race_result = server
.test_replace_in_file_mutate_before_write(
partial_mutate_race_path,
"token",
"updated",
false,
None,
Some(30_000),
)
.await
.expect("replace-in-file mutate-race call failed");
assert!(
partial_mutate_race_result.is_error.unwrap_or(false),
"partial mutate race should return conflict-like error"
);
let partial_mutate_race_text = extract_text_from_result(&partial_mutate_race_result);
let partial_mutate_race_json: serde_json::Value =
serde_json::from_str(partial_mutate_race_text.trim())
.expect("partial mutate-race response should be valid JSON conflict");
assert_eq!(
partial_mutate_race_json
.get("error")
.and_then(|v| v.as_str()),
Some("conflict")
);
let partial_mutate_expected = partial_mutate_race_json
.get("expected_sha256")
.and_then(|v| v.as_str())
.expect("partial mutate-race conflict should include expected_sha256");
let partial_mutate_actual = partial_mutate_race_json
.get("actual_sha256")
.and_then(|v| v.as_str())
.expect("partial mutate-race conflict should include actual_sha256");
assert_ne!(
partial_mutate_expected, partial_mutate_actual,
"partial mutate-race should detect changed target version"
);
let partial_mutate_race_read = server
.test_read_file(partial_mutate_race_path, None)
.await
.expect("read-file for partial mutate-race path failed");
let partial_mutate_race_read_text = extract_text_from_result(&partial_mutate_race_read);
let partial_mutate_race_read_json: serde_json::Value =
serde_json::from_str(partial_mutate_race_read_text.trim())
.expect("partial mutate-race read-file response should be valid JSON");
assert_eq!(
partial_mutate_race_read_json
.get("content")
.and_then(|v| v.as_str()),
Some("__ssh_mcp_race_injected__\n")
);
let apply_path = "/tmp/ssh-mcp-write-file.txt";
server
.test_execute_command(&format!(
"printf 'alpha\\n' > {}",
ssh_mcp::escape_for_shell(apply_path)
))
.await
.expect("failed to create write-file fixture file");
let (previous_hash, ticket) = read_file_ticket(&server, apply_path).await;
let apply_ok_result = server
.test_write_file(
apply_path,
"beta\n",
Some(previous_hash.as_str()),
Some(ticket.as_str()),
Some(30_000),
)
.await
.expect("write-file happy-path call failed");
assert!(
!apply_ok_result.is_error.unwrap_or(false),
"happy path should return success"
);
let apply_ok_text = extract_text_from_result(&apply_ok_result);
let apply_ok_json: serde_json::Value = serde_json::from_str(apply_ok_text.trim())
.expect("write-file happy-path response should be valid JSON");
assert_eq!(
apply_ok_json.get("path").and_then(|v| v.as_str()),
Some(apply_path)
);
assert_eq!(
apply_ok_json
.get("previous_sha256")
.and_then(|v| v.as_str()),
Some(previous_hash.as_str())
);
assert_eq!(
apply_ok_json.get("bytes_written").and_then(|v| v.as_u64()),
Some(5)
);
assert_eq!(
apply_ok_json.get("changed").and_then(|v| v.as_bool()),
Some(true)
);
let post_apply_read = server
.test_read_file(apply_path, None)
.await
.expect("failed to read write-file result");
let post_apply_text = extract_text_from_result(&post_apply_read);
let post_apply_json: serde_json::Value = serde_json::from_str(post_apply_text.trim())
.expect("post-apply read-file response should be valid JSON");
assert_eq!(
post_apply_json.get("content").and_then(|v| v.as_str()),
Some("beta\n")
);
let (preview_hash, preview_ticket) = read_file_ticket(&server, apply_path).await;
let preview_result = server
.test_write_file_with_params(ssh_mcp::tools::WriteFileParams {
remote_path: apply_path.to_string(),
new_content: "delta\n".to_string(),
expected_sha256: None,
read_ticket: Some(preview_ticket),
dry_run: Some(true),
timeout_ms: Some(30_000),
})
.await
.expect("write-file dry_run call failed");
assert!(
!preview_result.is_error.unwrap_or(false),
"write-file dry_run should return success"
);
let preview_text = extract_text_from_result(&preview_result);
let preview_json: serde_json::Value = serde_json::from_str(preview_text.trim())
.expect("write-file dry_run response should be valid JSON");
assert_eq!(
preview_json.get("dry_run").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
preview_json.get("previous_sha256").and_then(|v| v.as_str()),
Some(preview_hash.as_str())
);
assert!(
preview_json
.get("diff")
.and_then(|v| v.as_str())
.is_some_and(|diff| diff.contains("delta")),
"write-file dry_run should include diff preview: {preview_text}"
);
let post_preview_read = server
.test_read_file(apply_path, None)
.await
.expect("failed to read file after write-file dry_run");
let post_preview_text = extract_text_from_result(&post_preview_read);
let post_preview_json: serde_json::Value = serde_json::from_str(post_preview_text.trim())
.expect("post-preview read-file response should be valid JSON");
assert_eq!(
post_preview_json.get("content").and_then(|v| v.as_str()),
Some("beta\n")
);
let implicit_lock_path = "/tmp/ssh-mcp-write-file-implicit-ticket.txt";
server
.test_execute_command(&format!(
"printf 'ticket-base\\n' > {}",
ssh_mcp::escape_for_shell(implicit_lock_path)
))
.await
.expect("failed to create implicit ticket fixture file");
let (_implicit_hash, implicit_ticket) = read_file_ticket(&server, implicit_lock_path).await;
server
.test_execute_command(&format!(
"printf 'mutated-after-read\\n' > {}",
ssh_mcp::escape_for_shell(implicit_lock_path)
))
.await
.expect("failed to mutate implicit ticket fixture file");
let implicit_conflict_result = server
.test_write_file(
implicit_lock_path,
"should-not-apply\n",
None,
Some(implicit_ticket.as_str()),
Some(30_000),
)
.await
.expect("write-file implicit optimistic-lock call failed");
assert!(
implicit_conflict_result.is_error.unwrap_or(false),
"hash-bound read_ticket should conflict after intervening modification"
);
let implicit_conflict_text = extract_text_from_result(&implicit_conflict_result);
let implicit_conflict_json: serde_json::Value =
serde_json::from_str(implicit_conflict_text.trim())
.expect("implicit read_ticket conflict response should be valid JSON");
assert_eq!(
implicit_conflict_json.get("error").and_then(|v| v.as_str()),
Some("conflict")
);
assert_ne!(
implicit_conflict_json
.get("expected_sha256")
.and_then(|v| v.as_str()),
implicit_conflict_json
.get("actual_sha256")
.and_then(|v| v.as_str()),
"hash-bound read_ticket conflict should report different expected/actual hashes"
);
let implicit_conflict_read = server
.test_read_file(implicit_lock_path, None)
.await
.expect("failed to read implicit ticket fixture after conflict");
let implicit_conflict_read_text = extract_text_from_result(&implicit_conflict_read);
let implicit_conflict_read_json: serde_json::Value =
serde_json::from_str(implicit_conflict_read_text.trim())
.expect("implicit ticket follow-up read should be valid JSON");
assert_eq!(
implicit_conflict_read_json
.get("content")
.and_then(|v| v.as_str()),
Some("mutated-after-read\n")
);
let (_conflict_hash, conflict_ticket) = read_file_ticket(&server, apply_path).await;
let conflict_result = server
.test_write_file(
apply_path,
"gamma\n",
Some("0000000000000000000000000000000000000000000000000000000000000000"),
Some(conflict_ticket.as_str()),
Some(30_000),
)
.await
.expect("write-file conflict call failed");
assert!(
conflict_result.is_error.unwrap_or(false),
"mismatch hash should return an error"
);
let conflict_text = extract_text_from_result(&conflict_result);
let conflict_json: serde_json::Value =
serde_json::from_str(conflict_text.trim()).expect("conflict response should be valid JSON");
assert_eq!(
conflict_json.get("error").and_then(|v| v.as_str()),
Some("conflict")
);
assert_eq!(
conflict_json.get("path").and_then(|v| v.as_str()),
Some(apply_path)
);
let post_conflict_read = server
.test_read_file(apply_path, None)
.await
.expect("failed to read file after conflict");
let post_conflict_text = extract_text_from_result(&post_conflict_read);
let post_conflict_json: serde_json::Value = serde_json::from_str(post_conflict_text.trim())
.expect("post-conflict read-file response should be valid JSON");
assert_eq!(
post_conflict_json.get("content").and_then(|v| v.as_str()),
Some("beta\n")
);
server
.test_execute_command(&format!(
"printf 'race-base\\n' > {}",
ssh_mcp::escape_for_shell(apply_path)
))
.await
.expect("failed to reset write-file race fixture file");
let (race_expected, race_ticket) = read_file_ticket(&server, apply_path).await;
let server_a = server.clone();
let server_b = server.clone();
let race_expected_a = race_expected.clone();
let race_expected_b = race_expected.clone();
let race_ticket_a = race_ticket.clone();
let race_ticket_b = race_ticket.clone();
let (race_a_result, race_b_result) = tokio::join!(
async move {
server_a
.test_write_file(
apply_path,
"race-a\n",
Some(race_expected_a.as_str()),
Some(race_ticket_a.as_str()),
Some(30_000),
)
.await
},
async move {
server_b
.test_write_file(
apply_path,
"race-b\n",
Some(race_expected_b.as_str()),
Some(race_ticket_b.as_str()),
Some(30_000),
)
.await
}
);
let race_a_result = race_a_result.expect("first concurrent write-file call failed");
let race_b_result = race_b_result.expect("second concurrent write-file call failed");
let mut race_successes = 0usize;
let mut race_conflicts = 0usize;
for outcome in [&race_a_result, &race_b_result] {
let text = extract_text_from_result(outcome);
if outcome.is_error.unwrap_or(false) {
let conflict_json: serde_json::Value = serde_json::from_str(text.trim())
.expect("concurrent conflict response should be valid JSON");
assert_eq!(
conflict_json.get("error").and_then(|v| v.as_str()),
Some("conflict")
);
race_conflicts += 1;
} else {
let success_json: serde_json::Value = serde_json::from_str(text.trim())
.expect("concurrent success response should be valid JSON");
assert_eq!(
success_json.get("path").and_then(|v| v.as_str()),
Some(apply_path)
);
race_successes += 1;
}
}
assert_eq!(
race_successes, 1,
"exactly one concurrent writer should commit"
);
assert_eq!(
race_conflicts, 1,
"exactly one concurrent writer should return conflict"
);
let race_read = server
.test_read_file(apply_path, None)
.await
.expect("failed to read file after concurrent write-file race");
let race_text = extract_text_from_result(&race_read);
let race_json: serde_json::Value = serde_json::from_str(race_text.trim())
.expect("post-race read-file response should be valid JSON");
let race_content = race_json
.get("content")
.and_then(|v| v.as_str())
.expect("post-race read-file JSON should include content");
assert!(
race_content == "race-a\n" || race_content == "race-b\n",
"unexpected final race content: {race_content}"
);
let oversized_new_content = "x".repeat(1_048_577);
let (_oversized_hash, oversized_ticket) = read_file_ticket(&server, apply_path).await;
let oversized_apply_result = server
.test_write_file(
apply_path,
oversized_new_content.as_str(),
None,
Some(oversized_ticket.as_str()),
Some(30_000),
)
.await
.expect("write-file oversized call failed");
assert!(
oversized_apply_result.is_error.unwrap_or(false),
"oversized new_content should return an error"
);
let oversized_apply_text = extract_text_from_result(&oversized_apply_result);
assert!(
oversized_apply_text.contains(
"Error: new_content exceeds write-file size limit (1048576 bytes). Use transfer for large files"
),
"unexpected oversized write-file error: {oversized_apply_text}"
);
let post_oversized_read = server
.test_read_file(apply_path, None)
.await
.expect("failed to read file after oversized write-file rejection");
let post_oversized_text = extract_text_from_result(&post_oversized_read);
let post_oversized_json: serde_json::Value = serde_json::from_str(post_oversized_text.trim())
.expect("post-oversized read-file response should be valid JSON");
let post_oversized_content = post_oversized_json
.get("content")
.and_then(|v| v.as_str())
.expect("post-oversized read-file response should include content");
assert!(
post_oversized_content == "race-a\n" || post_oversized_content == "race-b\n",
"oversized write-file should not modify destination"
);
let lock_error_dir = "/tmp/ssh-mcp-apply-edit-lock-error";
let lock_error_file = "/tmp/ssh-mcp-apply-edit-lock-error/data.txt";
server
.test_execute_command(&format!(
"sh -c 'set -eu; rm -rf -- {dir}; mkdir -p -- {dir}; printf \"locked\\n\" > {file}; chmod 0555 -- {dir}'",
dir = ssh_mcp::escape_for_shell(lock_error_dir),
file = ssh_mcp::escape_for_shell(lock_error_file),
))
.await
.expect("failed to create lock-error fixture");
let (lock_error_hash, lock_error_ticket) = read_file_ticket(&server, lock_error_file).await;
let lock_error_result = server
.test_write_file(
lock_error_file,
"should-not-commit\n",
Some(lock_error_hash.as_str()),
Some(lock_error_ticket.as_str()),
Some(30_000),
)
.await
.expect("write-file lock-error call failed");
assert!(
lock_error_result.is_error.unwrap_or(false),
"lock acquisition failure should return an error"
);
let lock_error_text = extract_text_from_result(&lock_error_result);
assert!(
lock_error_text
.contains("failed to acquire remote write-file lock due to filesystem error"),
"lock acquisition error should be classified as hard failure: {lock_error_text}"
);
server
.test_execute_command(&format!(
"sh -c 'chmod 0755 -- {}; rm -rf -- {}'",
ssh_mcp::escape_for_shell(lock_error_dir),
ssh_mcp::escape_for_shell(lock_error_dir)
))
.await
.expect("failed to clean lock-error fixture");
let stale_lock_dir = "/tmp/ssh-mcp-stale-lock";
let stale_lock_file = "/tmp/ssh-mcp-stale-lock/data.txt";
let stale_lock_guard = format!("{}.ssh-mcp-lock", stale_lock_file);
server
.test_execute_command(&format!(
"sh -c 'set -eu; rm -rf -- {dir} {lock_dir}; mkdir -p -- {dir} {lock_dir}; printf \"stale-base\\n\" > {file}; stale_started_at=$(( $(date +%s) - 3600 )); printf \"%s\\n\" \"$stale_started_at\" > {lock_dir}/started_at; printf \"write-file\\n\" > {lock_dir}/operation'",
dir = ssh_mcp::escape_for_shell(stale_lock_dir),
file = ssh_mcp::escape_for_shell(stale_lock_file),
lock_dir = ssh_mcp::escape_for_shell(&stale_lock_guard),
))
.await
.expect("failed to create stale lock fixture");
let (stale_lock_hash, stale_lock_ticket) = read_file_ticket(&server, stale_lock_file).await;
let stale_lock_result = server
.test_write_file(
stale_lock_file,
"stale-lock-recovered\n",
Some(stale_lock_hash.as_str()),
Some(stale_lock_ticket.as_str()),
Some(30_000),
)
.await
.expect("write-file stale-lock recovery call failed");
assert!(
!stale_lock_result.is_error.unwrap_or(false),
"stale lock should be reclaimed automatically"
);
let stale_lock_read = server
.test_read_file(stale_lock_file, None)
.await
.expect("failed to read stale-lock file after recovery");
let stale_lock_text = extract_text_from_result(&stale_lock_read);
let stale_lock_json: serde_json::Value = serde_json::from_str(stale_lock_text.trim())
.expect("stale-lock read-file response should be valid JSON");
assert_eq!(
stale_lock_json.get("content").and_then(|v| v.as_str()),
Some("stale-lock-recovered\n")
);
let rollback_dir = "/tmp/ssh-mcp-apply-edit-rollback";
let rollback_file = "/tmp/ssh-mcp-apply-edit-rollback/data.txt";
server
.test_execute_command(&format!(
"sh -c 'set -eu; rm -rf -- {dir}; mkdir -p -- {dir}; printf \"rollback-base\\n\" > {file}'",
dir = ssh_mcp::escape_for_shell(rollback_dir),
file = ssh_mcp::escape_for_shell(rollback_file),
))
.await
.expect("failed to create rollback fixture");
let (rollback_hash, rollback_ticket) = read_file_ticket(&server, rollback_file).await;
let rollback_failure_result = server
.test_write_file_fail_before_finalize(
rollback_file,
"should-not-commit\n",
Some(rollback_hash.as_str()),
Some(rollback_ticket.as_str()),
Some(30_000),
)
.await
.expect("write-file rollback-injection call failed");
assert!(
rollback_failure_result.is_error.unwrap_or(false),
"rollback injection should return an error"
);
let rollback_read = server
.test_read_file(rollback_file, None)
.await
.expect("failed to read rollback file after injected failure");
let rollback_text = extract_text_from_result(&rollback_read);
let rollback_json: serde_json::Value = serde_json::from_str(rollback_text.trim())
.expect("rollback read-file response should be JSON");
assert_eq!(
rollback_json.get("content").and_then(|v| v.as_str()),
Some("rollback-base\n")
);
let rollback_hash_after = remote_sha256(&server, rollback_file).await;
assert_eq!(
rollback_hash_after, rollback_hash,
"failed write-file must not change destination hash"
);
let rollback_listing = server
.test_execute_command(&format!(
"sh -c 'set -eu; ls -1A -- {}'",
ssh_mcp::escape_for_shell(rollback_dir)
))
.await
.expect("failed to list rollback directory after injected failure");
let rollback_listing_text = extract_text_from_result(&rollback_listing);
let rollback_entries: Vec<&str> = rollback_listing_text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
assert_eq!(
rollback_entries,
vec!["data.txt"],
"injected failure should not leave staging or lock artifacts"
);
server
.test_execute_command(&format!(
"sh -c 'rm -rf -- {}'",
ssh_mcp::escape_for_shell(rollback_dir)
))
.await
.expect("failed to clean rollback fixture");
let sha_unavailable_dir = "/tmp/ssh-mcp-apply-edit-sha-unavailable";
let sha_unavailable_file = "/tmp/ssh-mcp-apply-edit-sha-unavailable/data.txt";
server
.test_execute_command(&format!(
"sh -c 'set -eu; rm -rf -- {dir}; mkdir -p -- {dir}; printf \"sha-base\\n\" > {file}'",
dir = ssh_mcp::escape_for_shell(sha_unavailable_dir),
file = ssh_mcp::escape_for_shell(sha_unavailable_file),
))
.await
.expect("failed to create sha-unavailable fixture");
let (sha_unavailable_hash_before, sha_unavailable_ticket) =
read_file_ticket(&server, sha_unavailable_file).await;
let sha_unavailable_result = server
.test_write_file_sha256_unavailable(
sha_unavailable_file,
"should-not-commit\n",
Some(sha_unavailable_hash_before.as_str()),
Some(sha_unavailable_ticket.as_str()),
Some(30_000),
)
.await
.expect("write-file sha-unavailable injection call failed");
assert!(
sha_unavailable_result.is_error.unwrap_or(false),
"sha-unavailable injection should return an error"
);
let sha_unavailable_text = extract_text_from_result(&sha_unavailable_result);
assert!(
sha_unavailable_text.contains("does not provide SHA-256 utilities"),
"sha-unavailable failure should report SHA-256 utility absence: {sha_unavailable_text}"
);
let sha_unavailable_read = server
.test_read_file(sha_unavailable_file, None)
.await
.expect("failed to read file after sha-unavailable failure");
let sha_unavailable_read_text = extract_text_from_result(&sha_unavailable_read);
let sha_unavailable_read_json: serde_json::Value =
serde_json::from_str(sha_unavailable_read_text.trim())
.expect("sha-unavailable read-file response should be valid JSON");
assert_eq!(
sha_unavailable_read_json
.get("content")
.and_then(|v| v.as_str()),
Some("sha-base\n")
);
let sha_unavailable_hash_after = remote_sha256(&server, sha_unavailable_file).await;
assert_eq!(
sha_unavailable_hash_after, sha_unavailable_hash_before,
"sha-unavailable write-file failure must not change destination hash"
);
let sha_unavailable_listing = server
.test_execute_command(&format!(
"sh -c 'set -eu; ls -1A -- {}'",
ssh_mcp::escape_for_shell(sha_unavailable_dir)
))
.await
.expect("failed to list sha-unavailable fixture directory after failure");
let sha_unavailable_listing_text = extract_text_from_result(&sha_unavailable_listing);
let sha_unavailable_entries: Vec<&str> = sha_unavailable_listing_text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
assert_eq!(
sha_unavailable_entries,
vec!["data.txt"],
"sha-unavailable failure should not leave staging or lock artifacts"
);
server
.test_execute_command(&format!(
"sh -c 'rm -rf -- {}'",
ssh_mcp::escape_for_shell(sha_unavailable_dir)
))
.await
.expect("failed to clean sha-unavailable fixture");
{
let rt_smoke_path = "/tmp/ssh-mcp-read-ticket-smoke.txt";
server
.test_execute_command(&format!(
"printf 'ticket-smoke\\n' > {}",
ssh_mcp::escape_for_shell(rt_smoke_path),
))
.await
.expect("failed to create read-ticket smoke fixture");
let rt_read_result = server
.test_read_file(rt_smoke_path, None)
.await
.expect("read-file for ticket smoke failed");
let rt_text = extract_text_from_result(&rt_read_result);
let rt_json: serde_json::Value = serde_json::from_str(rt_text.trim())
.expect("read-file ticket smoke should be valid JSON");
assert!(
rt_json.get("sha256").and_then(|v| v.as_str()).is_some(),
"read-file response must include sha256 field"
);
let rt_ticket = rt_json
.get("read_ticket")
.and_then(|v| v.as_str())
.expect("read-file response must include read_ticket field");
assert!(
rt_ticket.starts_with("rt2."),
"read_ticket must start with version prefix: {rt_ticket}"
);
assert_eq!(
rt_ticket.split('.').count(),
4,
"read_ticket must include version, expiry, content hash, and signature"
);
tracing::info!("read-file sha256 and read_ticket fields verified");
}
{
let rt_enforce_path = "/tmp/ssh-mcp-read-ticket-enforce.txt";
server
.test_execute_command(&format!(
"printf 'enforce-content\\n' > {}",
ssh_mcp::escape_for_shell(rt_enforce_path),
))
.await
.expect("failed to create enforcement fixture");
let enforce_result = server
.test_write_file(rt_enforce_path, "new-content\n", None, None, Some(30_000))
.await;
assert!(
enforce_result.is_err(),
"full-mode edit on non-empty file without read_ticket must be rejected"
);
let enforce_err = format!("{:?}", enforce_result.unwrap_err());
assert!(
enforce_err.contains("must be read before editing"),
"error should mention read-before-edit: {enforce_err}"
);
tracing::info!("read_ticket enforcement on non-empty file verified");
}
{
let rt_create_dir = "/tmp/ssh-mcp-read-ticket-create";
let rt_create_path = "/tmp/ssh-mcp-read-ticket-create/new-file.txt";
server
.test_execute_command(&format!(
"sh -c 'set -eu; rm -rf -- {dir}; mkdir -p -- {dir}'",
dir = ssh_mcp::escape_for_shell(rt_create_dir),
))
.await
.expect("failed to prepare read-ticket create fixture");
let create_result = server
.test_write_file(rt_create_path, "brand-new\n", None, None, Some(30_000))
.await
.expect("full-mode create without ticket should succeed");
assert!(
!create_result.is_error.unwrap_or(false),
"creating a missing file without read_ticket should succeed"
);
tracing::info!("read_ticket exemption for missing file verified");
}
{
let rt_blank_path = "/tmp/ssh-mcp-read-ticket-blank.txt";
server
.test_execute_command(&format!(
"printf 'blank-enforce\n' > {}",
ssh_mcp::escape_for_shell(rt_blank_path),
))
.await
.expect("failed to create blank-ticket enforcement fixture");
let blank_result = server
.test_write_file(
rt_blank_path,
"should-fail\n",
Some(""),
Some(""),
Some(30_000),
)
.await;
assert!(
blank_result.is_err(),
"blank placeholder ticket must still be rejected for existing non-empty files"
);
let blank_err = format!("{:?}", blank_result.unwrap_err());
assert!(
blank_err.contains("must be read before editing"),
"blank placeholder error should still mention read-before-edit: {blank_err}"
);
}
{
let rt_empty_path = "/tmp/ssh-mcp-read-ticket-empty.txt";
server
.test_execute_command(&format!(
"sh -c 'set -eu; : > {}'",
ssh_mcp::escape_for_shell(rt_empty_path),
))
.await
.expect("failed to create zero-byte fixture");
let empty_result = server
.test_write_file(rt_empty_path, "now-has-content\n", None, None, Some(30_000))
.await
.expect("full-mode edit on empty file without ticket should succeed");
assert!(
!empty_result.is_error.unwrap_or(false),
"editing a zero-byte file without read_ticket should succeed"
);
tracing::info!("read_ticket exemption for zero-byte file verified");
}
{
let rt_valid_path = "/tmp/ssh-mcp-read-ticket-valid.txt";
server
.test_execute_command(&format!(
"printf 'original\\n' > {}",
ssh_mcp::escape_for_shell(rt_valid_path),
))
.await
.expect("failed to create valid-ticket fixture");
let (valid_sha, valid_ticket) = read_file_ticket(&server, rt_valid_path).await;
let valid_result = server
.test_write_file(
rt_valid_path,
"updated\n",
Some(valid_sha.as_str()),
Some(valid_ticket.as_str()),
Some(30_000),
)
.await
.expect("full-mode edit with valid ticket should succeed");
assert!(
!valid_result.is_error.unwrap_or(false),
"editing with valid read_ticket should succeed"
);
let valid_text = extract_text_from_result(&valid_result);
let valid_json: serde_json::Value = serde_json::from_str(valid_text.trim())
.expect("valid-ticket edit response should be valid JSON");
assert_eq!(
valid_json.get("changed").and_then(|v| v.as_bool()),
Some(true)
);
tracing::info!("read_ticket valid ticket flow verified");
}
{
let rt_wrong_path_a = "/tmp/ssh-mcp-read-ticket-wrong-a.txt";
let rt_wrong_path_b = "/tmp/ssh-mcp-read-ticket-wrong-b.txt";
server
.test_execute_command(&format!(
"sh -c 'printf \"aaa\\n\" > {}; printf \"bbb\\n\" > {}'",
ssh_mcp::escape_for_shell(rt_wrong_path_a),
ssh_mcp::escape_for_shell(rt_wrong_path_b),
))
.await
.expect("failed to create wrong-ticket fixtures");
let (_sha_a, ticket_a) = read_file_ticket(&server, rt_wrong_path_a).await;
let wrong_result = server
.test_write_file(
rt_wrong_path_b,
"should-fail\n",
None,
Some(ticket_a.as_str()),
Some(30_000),
)
.await;
assert!(
wrong_result.is_err(),
"using read_ticket from a different path must be rejected"
);
let wrong_err = format!("{:?}", wrong_result.unwrap_err());
assert!(
wrong_err.contains("verification failed"),
"error should mention verification failure: {wrong_err}"
);
tracing::info!("read_ticket wrong-path rejection verified");
}
let timeout_result = server
.test_execute_command_with_timeout_ms("sleep 2; echo done", 1100)
.await
.expect("exec command with timeout override failed");
let timeout_text = extract_text_from_result(&timeout_result);
let timeout_json: serde_json::Value = serde_json::from_str(timeout_text.trim())
.expect("timeout->detach response should be valid JSON");
assert_eq!(
timeout_json.get("ok").and_then(|v| v.as_bool()),
Some(false)
);
assert_eq!(
timeout_json.get("timeout").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
timeout_json.get("background").and_then(|v| v.as_bool()),
Some(true)
);
assert!(
timeout_json.get("hint").and_then(|v| v.as_str()).is_some(),
"timeout->detach response should include a pragmatic hint"
);
let log_path = timeout_json
.get("log_path")
.and_then(|v| v.as_str())
.expect("timeout->detach response should include log_path");
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10);
let poll_interval = tokio::time::Duration::from_millis(250);
let mut last_log_text = String::new();
loop {
if tokio::time::Instant::now() >= deadline {
panic!(
"detached job log did not contain 'done' within deadline; last log: '{}'",
last_log_text
);
}
let log_text = match tokio::fs::read_to_string(log_path).await {
Ok(s) => s,
Err(e) => {
last_log_text = format!("<read error: {e}>");
tokio::time::sleep(poll_interval).await;
continue;
}
};
last_log_text = log_text.clone();
if log_text.contains("done") {
break;
}
tokio::time::sleep(poll_interval).await;
}
let sudo_result = server
.test_execute_sudo_command("whoami")
.await
.expect("sudo command failed");
let sudo_output = extract_text_from_result(&sudo_result);
let sudo_output = sudo_output.trim();
if sudo_output.contains("root") {
tracing::info!("sudo-exec tool verified: whoami returned 'root'");
} else {
tracing::warn!(
"sudo-exec 'whoami' did not return 'root', got: '{}'",
sudo_output
);
tracing::warn!("This may be due to container sudo configuration limitations");
}
server.shutdown().await;
tracing::info!("Server shut down successfully");
}
#[cfg(unix)]
mod unix_transfer_tests {
use super::*;
#[tokio::test]
async fn test_transfer_auto_uses_sftp_when_available() {
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_openssh_client("sftp") {
tracing::warn!("skipping: local 'sftp' client unavailable");
return;
}
if !check_openssh_client("scp") {
tracing::warn!("skipping: local 'scp' client unavailable");
return;
}
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");
}
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.clone()),
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();
assert!(!remote_home.is_empty(), "remote HOME should not be empty");
let unique = format!(
"{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let local_base =
std::path::PathBuf::from("target/tmp").join(format!("transfer-it-{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 transfer\n").expect("write local file");
let local_path_param = local_file.to_string_lossy().to_string();
let remote_file = format!("{remote_home}/hello.txt");
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 should succeed: {:?}", resp.error);
assert_eq!(
resp.transport_used,
TransferTransport::Sftp,
"auto should prefer sftp when available"
);
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 transfer"));
let local_dir = local_base.join("dir");
std::fs::create_dir_all(local_dir.join("nested")).expect("create local dir tree");
std::fs::write(local_dir.join("nested").join("a.txt"), "a\n").expect("write nested file");
let local_dir_param = local_dir.to_string_lossy().to_string();
let remote_dir = format!("{remote_home}/recv-dir");
let dir_resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_dir_param,
remote_path: remote_dir.clone(),
transport: TransferTransport::Auto,
kind: Some(TransferKind::Directory),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(
dir_resp.ok,
"directory transfer should succeed: {:?}",
dir_resp.error
);
let dir_verify = server
.test_execute_command(&format!(
"sh -c 'test -f {}/nested/a.txt && printf ok'",
ssh_mcp::escape_for_shell(&remote_dir)
))
.await
.expect("verify remote dir");
assert!(extract_text_from_result(&dir_verify).contains("ok"));
server.shutdown().await;
let _ = std::fs::remove_dir_all(&local_base);
}
}
#[tokio::test]
async fn test_compact_response_has_paths() {
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();
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: Some("secret".to_string()),
key: None,
su_password: None,
sudo_password: Some("secret".to_string()),
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: false,
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 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let temp = std::path::PathBuf::from("target/tmp").join(format!("compact-test-{unique}"));
std::fs::create_dir_all(&temp).expect("create temp dir");
let test_file = temp.join("test.txt");
tokio::fs::write(&test_file, "test content for compact response")
.await
.expect("write test file");
let local_path_param = test_file.to_string_lossy().to_string();
let remote_file = format!("{}/test_compact.txt", remote_home);
let resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param.clone(),
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 should succeed: {:?}", resp.error);
let compact_json = resp
.to_json(false)
.expect("compact JSON serialization failed");
let json: serde_json::Value =
serde_json::from_str(&compact_json).expect("compact JSON parse failed");
assert_eq!(json["ok"], true, "compact response should have ok=true");
assert_eq!(
json["local_path"].as_str().unwrap_or_default(),
local_path_param,
"compact response should have local_path"
);
assert_eq!(
json["remote_path"].as_str().unwrap_or_default(),
remote_file,
"compact response should have remote_path"
);
let bytes = json["counts"]["bytes"].as_u64().unwrap_or(0);
assert!(
bytes > 0,
"compact response should have counts.bytes > 0, got {bytes}"
);
assert!(
!json["transport_used"].is_null(),
"compact response should have transport_used"
);
assert!(
json["staging"].is_null(),
"compact response should NOT have staging"
);
assert!(
json["resolved"].is_null(),
"compact response should NOT have resolved"
);
let verbose_resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param.clone(),
remote_path: remote_file.clone(),
transport: TransferTransport::ExecRaw,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: true, ..Default::default()
})
.await;
assert!(
verbose_resp.ok,
"verbose transfer should succeed: {:?}",
verbose_resp.error
);
let verbose_json = verbose_resp
.to_json(true)
.expect("verbose JSON serialization failed");
let json: serde_json::Value =
serde_json::from_str(&verbose_json).expect("verbose JSON parse failed");
assert_eq!(json["ok"], true, "verbose response should have ok=true");
assert_eq!(
json["params"]["local_path"].as_str().unwrap_or_default(),
local_path_param,
"verbose response should have local_path in params"
);
assert_eq!(
json["params"]["remote_path"].as_str().unwrap_or_default(),
remote_file,
"verbose response should have remote_path in params"
);
assert!(
!json["transport_used"].is_null(),
"verbose response should have transport_used"
);
assert!(
!json["params"].is_null(),
"verbose response should have params"
);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&temp);
}