1use async_trait::async_trait;
16use std::path::Path;
17use std::path::PathBuf;
18use std::process::Stdio;
19use std::time::Duration;
20use tokio::io::AsyncReadExt;
21use tokio::process::Command;
22
23#[derive(Debug, Clone)]
25pub struct ReadResult {
26 pub bytes: Vec<u8>,
27}
28
29#[derive(Debug, Clone)]
31pub struct DirEntry {
32 pub name: String,
33 pub is_dir: bool,
34}
35
36#[derive(Debug, Clone)]
38pub struct ExecResult {
39 pub exit_code: Option<i32>,
40 pub stdout: String,
41 pub stderr: String,
42}
43
44#[async_trait]
50pub trait ToolTransport: Send + Sync + std::fmt::Debug {
51 async fn read_file(&self, path: &Path) -> std::io::Result<Vec<u8>>;
53
54 async fn write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()>;
56
57 async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<DirEntry>>;
59
60 async fn create_dir_all(&self, path: &Path) -> std::io::Result<()>;
62
63 async fn exec_shell(
66 &self,
67 command: &str,
68 cwd: &Path,
69 env: &[(String, String)],
70 timeout: Duration,
71 max_output_bytes: usize,
72 ) -> std::io::Result<ExecResult>;
73}
74
75#[derive(Debug, Clone)]
92pub struct SshTransport {
93 host: String,
95 key_path: Option<PathBuf>,
97 remote_workspace: PathBuf,
99 connect_timeout: Duration,
101 command_timeout: Duration,
103}
104
105impl SshTransport {
106 pub fn new(host: impl Into<String>, remote_workspace: impl Into<PathBuf>) -> Self {
111 Self {
112 host: host.into(),
113 key_path: None,
114 remote_workspace: remote_workspace.into(),
115 connect_timeout: Duration::from_secs(10),
116 command_timeout: Duration::from_secs(300),
117 }
118 }
119
120 pub fn with_key(mut self, key_path: impl Into<PathBuf>) -> Self {
122 self.key_path = Some(key_path.into());
123 self
124 }
125
126 pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
128 self.connect_timeout = timeout;
129 self
130 }
131
132 pub fn with_command_timeout(mut self, timeout: Duration) -> Self {
134 self.command_timeout = timeout;
135 self
136 }
137
138 pub fn host(&self) -> &str {
140 &self.host
141 }
142
143 pub fn key_path(&self) -> Option<&Path> {
145 self.key_path.as_deref()
146 }
147
148 pub fn remote_workspace(&self) -> &Path {
150 &self.remote_workspace
151 }
152
153 fn build_ssh_command(&self, remote_command: &str) -> Command {
155 let mut cmd = Command::new("ssh");
156
157 cmd.arg("-o").arg("BatchMode=yes");
159 cmd.arg("-o").arg("StrictHostKeyChecking=no");
160 cmd.arg("-o").arg("UserKnownHostsFile=/dev/null");
161 cmd.arg("-o")
162 .arg(format!("ConnectTimeout={}", self.connect_timeout.as_secs()));
163
164 if let Some(ref key) = self.key_path {
166 cmd.arg("-i").arg(key);
167 }
168
169 let (host, port) = parse_host(&self.host);
171 if let Some(p) = port {
172 cmd.arg("-p").arg(p.to_string());
173 }
174
175 cmd.arg(&host);
176 cmd.arg(remote_command);
177
178 cmd.stdout(Stdio::piped());
179 cmd.stderr(Stdio::piped());
180
181 cmd
182 }
183
184 async fn ssh_exec(&self, command: &str) -> std::io::Result<ExecResult> {
186 let mut child = self.build_ssh_command(command).spawn()?;
187
188 let mut stdout = child.stdout.take().expect("stdout piped");
189 let mut stderr = child.stderr.take().expect("stderr piped");
190
191 let max: usize = 128 * 1024;
192 let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
193 let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
194
195 let wait = child.wait();
196 let status = match tokio::time::timeout(self.command_timeout, wait).await {
197 Ok(s) => s?,
198 Err(_) => {
199 return Err(std::io::Error::new(
200 std::io::ErrorKind::TimedOut,
201 format!("SSH command timed out after {:?}", self.command_timeout),
202 ));
203 }
204 };
205
206 let out = stdout_task.await.unwrap_or_default();
207 let err = stderr_task.await.unwrap_or_default();
208 let code = status.code();
209
210 Ok(ExecResult {
211 exit_code: code,
212 stdout: out,
213 stderr: err,
214 })
215 }
216
217 async fn ssh_write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
219 if let Some(parent) = path.parent() {
221 let mkdir_cmd = format!(
222 "mkdir -p {}",
223 shell_escape(parent.to_string_lossy().as_ref())
224 );
225 let result = self.ssh_exec(&mkdir_cmd).await?;
226 if result.exit_code != Some(0) {
227 return Err(std::io::Error::other(format!(
228 "mkdir failed on remote: {}",
229 result.stderr
230 )));
231 }
232 }
233
234 let b64 = base64_encode(contents);
236 let write_cmd = format!(
237 "echo '{}' | base64 -d > {}",
238 b64,
239 shell_escape(path.to_string_lossy().as_ref())
240 );
241 let result = self.ssh_exec(&write_cmd).await?;
242 if result.exit_code != Some(0) {
243 return Err(std::io::Error::other(format!(
244 "write failed on remote: {}",
245 result.stderr
246 )));
247 }
248 Ok(())
249 }
250}
251
252#[async_trait]
253impl ToolTransport for SshTransport {
254 async fn read_file(&self, path: &Path) -> std::io::Result<Vec<u8>> {
255 let cmd = format!("cat {}", shell_escape(path.to_string_lossy().as_ref()));
256 let result = self.ssh_exec(&cmd).await?;
257 if result.exit_code != Some(0) {
258 return Err(std::io::Error::new(
259 std::io::ErrorKind::NotFound,
260 format!(
261 "remote file not found: {}: {}",
262 path.display(),
263 result.stderr
264 ),
265 ));
266 }
267 Ok(result.stdout.into_bytes())
268 }
269
270 async fn write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
271 self.ssh_write_file(path, contents).await
272 }
273
274 async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<DirEntry>> {
275 let cmd = format!("ls -1a {}", shell_escape(path.to_string_lossy().as_ref()));
276 let result = self.ssh_exec(&cmd).await?;
277 if result.exit_code != Some(0) {
278 return Err(std::io::Error::new(
279 std::io::ErrorKind::NotFound,
280 format!(
281 "remote dir not found: {}: {}",
282 path.display(),
283 result.stderr
284 ),
285 ));
286 }
287
288 let mut entries = Vec::new();
289 for name in result.stdout.lines() {
290 let name = name.trim();
291 if name.is_empty() || name == "." || name == ".." {
292 continue;
293 }
294 let test_cmd = format!(
296 "test -d {} && echo dir || echo file",
297 shell_escape(&format!("{}/{}", path.to_string_lossy(), name))
298 );
299 let is_dir = self
300 .ssh_exec(&test_cmd)
301 .await
302 .ok()
303 .map(|r| r.stdout.trim() == "dir")
304 .unwrap_or(false);
305 entries.push(DirEntry {
306 name: name.to_string(),
307 is_dir,
308 });
309 }
310
311 Ok(entries)
312 }
313
314 async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
315 let cmd = format!("mkdir -p {}", shell_escape(path.to_string_lossy().as_ref()));
316 let result = self.ssh_exec(&cmd).await?;
317 if result.exit_code != Some(0) {
318 return Err(std::io::Error::other(format!(
319 "mkdir failed on remote: {}",
320 result.stderr
321 )));
322 }
323 Ok(())
324 }
325
326 async fn exec_shell(
327 &self,
328 command: &str,
329 cwd: &Path,
330 env: &[(String, String)],
331 timeout: Duration,
332 max_output_bytes: usize,
333 ) -> std::io::Result<ExecResult> {
334 let mut env_prefix = String::new();
336 for (key, val) in env {
337 env_prefix.push_str(&format!("{}={} ", key, shell_escape(val)));
338 }
339
340 let remote_cmd = format!(
341 "cd {} && {} {}",
342 shell_escape(cwd.to_string_lossy().as_ref()),
343 env_prefix,
344 command
345 );
346
347 let mut child = self.build_ssh_command(&remote_cmd).spawn()?;
348
349 let mut stdout = child.stdout.take().expect("stdout piped");
350 let mut stderr = child.stderr.take().expect("stderr piped");
351
352 let max = max_output_bytes;
353 let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
354 let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
355
356 let wait = child.wait();
357 let status = match tokio::time::timeout(timeout, wait).await {
358 Ok(s) => s?,
359 Err(_) => {
360 return Err(std::io::Error::new(
361 std::io::ErrorKind::TimedOut,
362 format!("SSH command timed out after {:?}", timeout),
363 ));
364 }
365 };
366
367 let out = stdout_task.await.unwrap_or_default();
368 let err = stderr_task.await.unwrap_or_default();
369 let code = status.code();
370
371 Ok(ExecResult {
372 exit_code: code,
373 stdout: out,
374 stderr: err,
375 })
376 }
377}
378
379#[derive(Debug, Clone, Default)]
386pub struct LocalTransport;
387
388#[async_trait]
389impl ToolTransport for LocalTransport {
390 async fn read_file(&self, path: &Path) -> std::io::Result<Vec<u8>> {
391 tokio::fs::read(path).await
392 }
393
394 async fn write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
395 if let Some(parent) = path.parent() {
396 self.create_dir_all(parent).await?;
397 }
398 tokio::fs::write(path, contents).await
399 }
400
401 async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<DirEntry>> {
402 let mut read_dir = tokio::fs::read_dir(path).await?;
403 let mut entries = Vec::new();
404 while let Some(entry) = read_dir.next_entry().await? {
405 let name = entry.file_name().to_string_lossy().to_string();
406 let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
407 entries.push(DirEntry { name, is_dir });
408 }
409 Ok(entries)
410 }
411
412 async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
413 tokio::fs::create_dir_all(path).await
414 }
415
416 async fn exec_shell(
417 &self,
418 command: &str,
419 cwd: &Path,
420 env: &[(String, String)],
421 timeout: Duration,
422 max_output_bytes: usize,
423 ) -> std::io::Result<ExecResult> {
424 let mut cmd = Command::new("/bin/sh");
425 cmd.arg("-c").arg(command);
426 cmd.current_dir(cwd);
427 cmd.stdout(Stdio::piped());
428 cmd.stderr(Stdio::piped());
429
430 for (key, val) in env {
431 cmd.env(key, val);
432 }
433
434 let mut child = cmd.spawn()?;
435
436 let mut stdout = child.stdout.take().expect("stdout piped");
437 let mut stderr = child.stderr.take().expect("stderr piped");
438
439 let max = max_output_bytes;
440 let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
441 let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
442
443 let wait = child.wait();
444 let status = match tokio::time::timeout(timeout, wait).await {
445 Ok(s) => s?,
446 Err(_) => {
447 return Err(std::io::Error::new(
448 std::io::ErrorKind::TimedOut,
449 format!("command timed out after {:?}", timeout),
450 ));
451 }
452 };
453
454 let out = stdout_task.await.unwrap_or_default();
455 let err = stderr_task.await.unwrap_or_default();
456 let code = status.code();
457
458 Ok(ExecResult {
459 exit_code: code,
460 stdout: out,
461 stderr: err,
462 })
463 }
464}
465
466async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
471 let mut buf = Vec::with_capacity(8 * 1024);
472 let mut tmp = [0u8; 8 * 1024];
473 loop {
474 match reader.read(&mut tmp).await {
475 Ok(0) => break,
476 Ok(n) => {
477 if buf.len() + n > max {
478 let take = max.saturating_sub(buf.len());
479 buf.extend_from_slice(&tmp[..take]);
480 buf.extend_from_slice(b"\n... [output truncated]");
481 let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
482 break;
483 }
484 buf.extend_from_slice(&tmp[..n]);
485 }
486 Err(_) => break,
487 }
488 }
489 String::from_utf8_lossy(&buf).into_owned()
490}
491
492fn parse_host(host: &str) -> (String, Option<u16>) {
495 if let Some(at_pos) = host.rfind('@') {
497 let after_at = &host[at_pos + 1..];
498 if let Some(colon_pos) = after_at.rfind(':') {
499 let host_part = format!("{}@{}", &host[..at_pos], &after_at[..colon_pos]);
500 let port: u16 = after_at[colon_pos + 1..].parse().unwrap_or(22);
501 return (host_part, Some(port));
502 }
503 } else {
504 if let Some(colon_pos) = host.rfind(':') {
506 let host_part = host[..colon_pos].to_string();
507 let port: u16 = host[colon_pos + 1..].parse().unwrap_or(22);
508 return (host_part, Some(port));
509 }
510 }
511 (host.to_string(), None)
512}
513
514fn shell_escape(s: &str) -> String {
517 let escaped = s.replace('\'', "'\\''");
518 format!("'{}'", escaped)
519}
520
521fn base64_encode(bytes: &[u8]) -> String {
523 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
524 let mut result = String::new();
525 for chunk in bytes.chunks(3) {
526 let b0 = chunk[0] as u32;
527 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
528 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
529 let triple = (b0 << 16) | (b1 << 8) | b2;
530
531 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
532 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
533
534 if chunk.len() > 1 {
535 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
536 } else {
537 result.push('=');
538 }
539
540 if chunk.len() > 2 {
541 result.push(CHARS[(triple & 0x3F) as usize] as char);
542 } else {
543 result.push('=');
544 }
545 }
546 result
547}
548
549#[cfg(test)]
554mod tests {
555 use super::*;
556 use tempfile::TempDir;
557
558 #[tokio::test]
561 async fn local_transport_read_write_roundtrip() {
562 let t = LocalTransport;
563 let tmp = TempDir::new().unwrap();
564 let path = tmp.path().join("hello.txt");
565
566 t.write_file(&path, b"world").await.unwrap();
567 let data = t.read_file(&path).await.unwrap();
568 assert_eq!(data, b"world");
569 }
570
571 #[tokio::test]
572 async fn local_transport_list_dir() {
573 let t = LocalTransport;
574 let tmp = TempDir::new().unwrap();
575 std::fs::write(tmp.path().join("a.txt"), "x").unwrap();
576 std::fs::create_dir(tmp.path().join("sub")).unwrap();
577
578 let entries = t.list_dir(tmp.path()).await.unwrap();
579 let mut names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
580 names.sort();
581 assert_eq!(names, vec!["a.txt", "sub"]);
582 assert!(!entries.iter().any(|e| e.name == "a.txt" && e.is_dir));
583 assert!(entries.iter().any(|e| e.name == "sub" && e.is_dir));
584 }
585
586 #[tokio::test]
587 async fn local_transport_exec_shell() {
588 let t = LocalTransport;
589 let tmp = TempDir::new().unwrap();
590 let result = t
591 .exec_shell(
592 "echo hello",
593 tmp.path(),
594 &[],
595 Duration::from_secs(5),
596 128 * 1024,
597 )
598 .await
599 .unwrap();
600 assert_eq!(result.exit_code, Some(0));
601 assert!(result.stdout.contains("hello"));
602 }
603
604 #[tokio::test]
605 async fn local_transport_exec_shell_with_env() {
606 let t = LocalTransport;
607 let tmp = TempDir::new().unwrap();
608 let result = t
609 .exec_shell(
610 "echo $MY_VAR",
611 tmp.path(),
612 &[("MY_VAR".into(), "test_value".into())],
613 Duration::from_secs(5),
614 128 * 1024,
615 )
616 .await
617 .unwrap();
618 assert_eq!(result.exit_code, Some(0));
619 assert!(result.stdout.contains("test_value"));
620 }
621
622 #[tokio::test]
623 async fn local_transport_exec_shell_timeout() {
624 let t = LocalTransport;
625 let tmp = TempDir::new().unwrap();
626 let result = t
627 .exec_shell(
628 "sleep 10",
629 tmp.path(),
630 &[],
631 Duration::from_millis(100),
632 128 * 1024,
633 )
634 .await;
635 assert!(result.is_err());
636 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
637 }
638
639 #[tokio::test]
640 async fn local_transport_create_dir_all() {
641 let t = LocalTransport;
642 let tmp = TempDir::new().unwrap();
643 let path = tmp.path().join("a").join("b").join("c");
644 t.create_dir_all(&path).await.unwrap();
645 assert!(path.exists());
646 assert!(path.is_dir());
647 }
648
649 #[test]
652 fn ssh_transport_constructor() {
653 let t = SshTransport::new("user@host", "/remote/workspace");
654 assert_eq!(t.host(), "user@host");
655 assert_eq!(t.remote_workspace(), Path::new("/remote/workspace"));
656 assert!(t.key_path().is_none());
657 }
658
659 #[test]
660 fn ssh_transport_with_key() {
661 let t = SshTransport::new("user@host", "/remote/workspace").with_key("/path/to/key");
662 assert_eq!(t.key_path(), Some(Path::new("/path/to/key")));
663 }
664
665 #[test]
666 fn ssh_transport_with_timeouts() {
667 let t = SshTransport::new("user@host", "/remote/workspace")
668 .with_connect_timeout(Duration::from_secs(30))
669 .with_command_timeout(Duration::from_secs(600));
670 assert_eq!(t.host(), "user@host");
673 }
674
675 #[test]
676 fn ssh_build_command_basic() {
677 let t = SshTransport::new("user@host", "/remote/workspace");
678 let cmd = t.build_ssh_command("ls -la");
679 let args: Vec<String> = cmd
680 .as_std()
681 .get_args()
682 .map(|s| s.to_string_lossy().to_string())
683 .collect();
684
685 assert!(args.contains(&"user@host".to_string()));
687 assert!(args.contains(&"ls -la".to_string()));
688 assert!(args.contains(&"-o".to_string()));
690 assert!(args.contains(&"BatchMode=yes".to_string()));
691 assert!(args.contains(&"StrictHostKeyChecking=no".to_string()));
693 }
694
695 #[test]
696 fn ssh_build_command_with_key() {
697 let t =
698 SshTransport::new("user@host", "/remote/workspace").with_key("/home/user/.ssh/id_rsa");
699 let cmd = t.build_ssh_command("echo test");
700 let args: Vec<String> = cmd
701 .as_std()
702 .get_args()
703 .map(|s| s.to_string_lossy().to_string())
704 .collect();
705
706 assert!(args.contains(&"-i".to_string()));
707 assert!(args.contains(&"/home/user/.ssh/id_rsa".to_string()));
708 }
709
710 #[test]
711 fn ssh_build_command_with_port() {
712 let t = SshTransport::new("user@host:2222", "/remote/workspace");
713 let cmd = t.build_ssh_command("whoami");
714 let args: Vec<String> = cmd
715 .as_std()
716 .get_args()
717 .map(|s| s.to_string_lossy().to_string())
718 .collect();
719
720 assert!(args.contains(&"-p".to_string()));
721 assert!(args.contains(&"2222".to_string()));
722 assert!(args.contains(&"user@host".to_string()));
723 }
724
725 #[test]
726 fn ssh_build_command_without_user() {
727 let t = SshTransport::new("remote-server:2222", "/remote/workspace");
729 let cmd = t.build_ssh_command("whoami");
730 let args: Vec<String> = cmd
731 .as_std()
732 .get_args()
733 .map(|s| s.to_string_lossy().to_string())
734 .collect();
735
736 assert!(args.contains(&"-p".to_string()));
737 assert!(args.contains(&"2222".to_string()));
738 assert!(args.contains(&"remote-server".to_string()));
739 }
740
741 #[test]
742 fn parse_host_user_at_host() {
743 let (host, port) = parse_host("user@host");
744 assert_eq!(host, "user@host");
745 assert_eq!(port, None);
746 }
747
748 #[test]
749 fn parse_host_user_at_host_port() {
750 let (host, port) = parse_host("user@host:2222");
751 assert_eq!(host, "user@host");
752 assert_eq!(port, Some(2222));
753 }
754
755 #[test]
756 fn parse_host_just_host() {
757 let (host, port) = parse_host("remote-server");
758 assert_eq!(host, "remote-server");
759 assert_eq!(port, None);
760 }
761
762 #[test]
763 fn parse_host_host_with_port_no_user() {
764 let (host, port) = parse_host("remote-server:2222");
765 assert_eq!(host, "remote-server");
766 assert_eq!(port, Some(2222));
767 }
768
769 #[test]
770 fn parse_host_invalid_port_defaults_to_none() {
771 let (host, port) = parse_host("user@host:notanumber");
773 assert_eq!(host, "user@host");
775 assert_eq!(port, Some(22));
776 }
777
778 #[test]
779 fn shell_escape_simple() {
780 assert_eq!(shell_escape("hello"), "'hello'");
781 }
782
783 #[test]
784 fn shell_escape_with_single_quote() {
785 assert_eq!(shell_escape("it's"), "'it'\\''s'");
786 }
787
788 #[test]
789 fn shell_escape_with_spaces() {
790 assert_eq!(
791 shell_escape("/home/user/my project"),
792 "'/home/user/my project'"
793 );
794 }
795
796 #[test]
797 fn shell_escape_empty() {
798 assert_eq!(shell_escape(""), "''");
799 }
800
801 #[test]
802 fn base64_encode_empty() {
803 assert_eq!(base64_encode(b""), "");
804 }
805
806 #[test]
807 fn base64_encode_hello() {
808 assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
810 }
811
812 #[test]
813 fn base64_encode_three_bytes() {
814 assert_eq!(base64_encode(b"abc"), "YWJj");
816 }
817
818 #[test]
819 fn base64_encode_binary() {
820 let bytes = vec![0x00, 0xFF, 0xFE, 0x7F];
821 let encoded = base64_encode(&bytes);
822 assert!(!encoded.is_empty());
823 assert!(encoded.len() % 4 == 0);
825 }
826
827 #[test]
828 fn ssh_transport_is_send_sync() {
829 fn assert_send_sync<T: Send + Sync>() {}
831 assert_send_sync::<SshTransport>();
832 }
833
834 #[test]
835 fn ssh_transport_debug() {
836 let t = SshTransport::new("user@host", "/remote/workspace");
837 let debug = format!("{:?}", t);
838 assert!(debug.contains("SshTransport"));
839 assert!(debug.contains("user@host"));
840 }
841
842 #[test]
843 fn ssh_transport_clone() {
844 let t = SshTransport::new("user@host", "/remote/workspace").with_key("/path/to/key");
845 let t2 = t.clone();
846 assert_eq!(t.host(), t2.host());
847 assert_eq!(t.key_path(), t2.key_path());
848 assert_eq!(t.remote_workspace(), t2.remote_workspace());
849 }
850
851 #[test]
852 fn ssh_transport_implements_tool_transport() {
853 fn assert_tool_transport<T: ToolTransport>() {}
855 assert_tool_transport::<SshTransport>();
856 }
857
858 #[test]
859 fn local_transport_implements_tool_transport() {
860 fn assert_tool_transport<T: ToolTransport>() {}
861 assert_tool_transport::<LocalTransport>();
862 }
863
864 #[test]
866 fn ssh_build_command_connect_timeout() {
867 let t = SshTransport::new("user@host", "/remote/workspace")
868 .with_connect_timeout(Duration::from_secs(15));
869 let cmd = t.build_ssh_command("echo test");
870 let args: Vec<String> = cmd
871 .as_std()
872 .get_args()
873 .map(|s| s.to_string_lossy().to_string())
874 .collect();
875
876 assert!(args.contains(&"ConnectTimeout=15".to_string()));
877 }
878
879 #[test]
881 fn ssh_build_command_known_hosts() {
882 let t = SshTransport::new("user@host", "/remote/workspace");
883 let cmd = t.build_ssh_command("echo test");
884 let args: Vec<String> = cmd
885 .as_std()
886 .get_args()
887 .map(|s| s.to_string_lossy().to_string())
888 .collect();
889
890 assert!(args.contains(&"UserKnownHostsFile=/dev/null".to_string()));
891 }
892}