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 insecure_host_key_checking: bool,
107}
108
109impl SshTransport {
110 pub fn new(host: impl Into<String>, remote_workspace: impl Into<PathBuf>) -> Self {
115 Self {
116 host: host.into(),
117 key_path: None,
118 remote_workspace: remote_workspace.into(),
119 connect_timeout: Duration::from_secs(10),
120 command_timeout: Duration::from_secs(300),
121 insecure_host_key_checking: false,
122 }
123 }
124
125 pub fn with_key(mut self, key_path: impl Into<PathBuf>) -> Self {
127 self.key_path = Some(key_path.into());
128 self
129 }
130
131 pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
133 self.connect_timeout = timeout;
134 self
135 }
136
137 pub fn with_command_timeout(mut self, timeout: Duration) -> Self {
139 self.command_timeout = timeout;
140 self
141 }
142
143 pub fn with_insecure_host_key_checking(mut self, on: bool) -> Self {
153 self.insecure_host_key_checking = on;
154 self
155 }
156
157 pub fn host(&self) -> &str {
159 &self.host
160 }
161
162 pub fn key_path(&self) -> Option<&Path> {
164 self.key_path.as_deref()
165 }
166
167 pub fn remote_workspace(&self) -> &Path {
169 &self.remote_workspace
170 }
171
172 fn build_ssh_command(&self, remote_command: &str) -> Command {
174 let mut cmd = Command::new("ssh");
175
176 cmd.arg("-o").arg("BatchMode=yes");
178 cmd.arg("-o")
179 .arg(format!("ConnectTimeout={}", self.connect_timeout.as_secs()));
180
181 if self.insecure_host_key_checking {
184 tracing::warn!(
185 host = %self.host,
186 "SshTransport: host key verification relaxed to StrictHostKeyChecking=accept-new \
187 (MITM-unsafe — only for trusted networks or disposable sandboxes)"
188 );
189 cmd.arg("-o").arg("StrictHostKeyChecking=accept-new");
190 }
191
192 if let Some(ref key) = self.key_path {
194 cmd.arg("-i").arg(key);
195 }
196
197 let (host, port) = parse_host(&self.host);
199 if let Some(p) = port {
200 cmd.arg("-p").arg(p.to_string());
201 }
202
203 cmd.arg(&host);
204 cmd.arg(remote_command);
205
206 cmd.stdout(Stdio::piped());
207 cmd.stderr(Stdio::piped());
208
209 cmd
210 }
211
212 async fn ssh_exec(&self, command: &str) -> std::io::Result<ExecResult> {
214 let mut child = self.build_ssh_command(command).spawn()?;
215
216 let mut stdout = child.stdout.take().ok_or_else(|| {
217 std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stdout pipe not available")
218 })?;
219 let mut stderr = child.stderr.take().ok_or_else(|| {
220 std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stderr pipe not available")
221 })?;
222
223 let max: usize = 128 * 1024;
224 let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
225 let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
226
227 let wait = child.wait();
228 let status = match tokio::time::timeout(self.command_timeout, wait).await {
229 Ok(s) => s?,
230 Err(_) => {
231 let _ = child.start_kill();
239 let _ = child.wait().await;
240 let _ = stdout_task.await;
241 let _ = stderr_task.await;
242 return Err(std::io::Error::new(
243 std::io::ErrorKind::TimedOut,
244 format!("SSH command timed out after {:?}", self.command_timeout),
245 ));
246 }
247 };
248
249 let out = stdout_task.await.unwrap_or_default();
250 let err = stderr_task.await.unwrap_or_default();
251 let code = status.code();
252
253 Ok(ExecResult {
254 exit_code: code,
255 stdout: out,
256 stderr: err,
257 })
258 }
259
260 async fn ssh_write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
262 if let Some(parent) = path.parent() {
264 let mkdir_cmd = format!(
265 "mkdir -p {}",
266 shell_escape(parent.to_string_lossy().as_ref())
267 );
268 let result = self.ssh_exec(&mkdir_cmd).await?;
269 if result.exit_code != Some(0) {
270 return Err(std::io::Error::other(format!(
271 "mkdir failed on remote: {}",
272 result.stderr
273 )));
274 }
275 }
276
277 let b64 = base64_encode(contents);
279 let write_cmd = format!(
280 "echo '{}' | base64 -d > {}",
281 b64,
282 shell_escape(path.to_string_lossy().as_ref())
283 );
284 let result = self.ssh_exec(&write_cmd).await?;
285 if result.exit_code != Some(0) {
286 return Err(std::io::Error::other(format!(
287 "write failed on remote: {}",
288 result.stderr
289 )));
290 }
291 Ok(())
292 }
293}
294
295#[async_trait]
296impl ToolTransport for SshTransport {
297 async fn read_file(&self, path: &Path) -> std::io::Result<Vec<u8>> {
298 let cmd = format!("cat {}", shell_escape(path.to_string_lossy().as_ref()));
299 let result = self.ssh_exec(&cmd).await?;
300 if result.exit_code != Some(0) {
301 return Err(std::io::Error::new(
302 std::io::ErrorKind::NotFound,
303 format!(
304 "remote file not found: {}: {}",
305 path.display(),
306 result.stderr
307 ),
308 ));
309 }
310 Ok(result.stdout.into_bytes())
311 }
312
313 async fn write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
314 self.ssh_write_file(path, contents).await
315 }
316
317 async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<DirEntry>> {
318 let cmd = format!("ls -1a {}", shell_escape(path.to_string_lossy().as_ref()));
319 let result = self.ssh_exec(&cmd).await?;
320 if result.exit_code != Some(0) {
321 return Err(std::io::Error::new(
322 std::io::ErrorKind::NotFound,
323 format!(
324 "remote dir not found: {}: {}",
325 path.display(),
326 result.stderr
327 ),
328 ));
329 }
330
331 let mut entries = Vec::new();
332 for name in result.stdout.lines() {
333 let name = name.trim();
334 if name.is_empty() || name == "." || name == ".." {
335 continue;
336 }
337 let test_cmd = format!(
339 "test -d {} && echo dir || echo file",
340 shell_escape(&format!("{}/{}", path.to_string_lossy(), name))
341 );
342 let is_dir = self
343 .ssh_exec(&test_cmd)
344 .await
345 .ok()
346 .map(|r| r.stdout.trim() == "dir")
347 .unwrap_or(false);
348 entries.push(DirEntry {
349 name: name.to_string(),
350 is_dir,
351 });
352 }
353
354 Ok(entries)
355 }
356
357 async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
358 let cmd = format!("mkdir -p {}", shell_escape(path.to_string_lossy().as_ref()));
359 let result = self.ssh_exec(&cmd).await?;
360 if result.exit_code != Some(0) {
361 return Err(std::io::Error::other(format!(
362 "mkdir failed on remote: {}",
363 result.stderr
364 )));
365 }
366 Ok(())
367 }
368
369 async fn exec_shell(
370 &self,
371 command: &str,
372 cwd: &Path,
373 env: &[(String, String)],
374 timeout: Duration,
375 max_output_bytes: usize,
376 ) -> std::io::Result<ExecResult> {
377 let mut env_prefix = String::new();
379 for (key, val) in env {
380 env_prefix.push_str(&format!("{}={} ", key, shell_escape(val)));
381 }
382
383 let remote_cmd = format!(
384 "cd {} && {} {}",
385 shell_escape(cwd.to_string_lossy().as_ref()),
386 env_prefix,
387 command
388 );
389
390 let mut child = self.build_ssh_command(&remote_cmd).spawn()?;
391
392 let mut stdout = child.stdout.take().ok_or_else(|| {
393 std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stdout pipe not available")
394 })?;
395 let mut stderr = child.stderr.take().ok_or_else(|| {
396 std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stderr pipe not available")
397 })?;
398
399 let max = max_output_bytes;
400 let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
401 let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
402
403 let wait = child.wait();
404 let status = match tokio::time::timeout(timeout, wait).await {
405 Ok(s) => s?,
406 Err(_) => {
407 let _ = child.start_kill();
412 let _ = child.wait().await;
413 let _ = stdout_task.await;
414 let _ = stderr_task.await;
415 return Err(std::io::Error::new(
416 std::io::ErrorKind::TimedOut,
417 format!("SSH command timed out after {:?}", timeout),
418 ));
419 }
420 };
421
422 let out = stdout_task.await.unwrap_or_default();
423 let err = stderr_task.await.unwrap_or_default();
424 let code = status.code();
425
426 Ok(ExecResult {
427 exit_code: code,
428 stdout: out,
429 stderr: err,
430 })
431 }
432}
433
434#[derive(Debug, Clone, Default)]
441pub struct LocalTransport;
442
443#[async_trait]
444impl ToolTransport for LocalTransport {
445 async fn read_file(&self, path: &Path) -> std::io::Result<Vec<u8>> {
446 tokio::fs::read(path).await
447 }
448
449 async fn write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
450 if let Some(parent) = path.parent() {
451 self.create_dir_all(parent).await?;
452 }
453 tokio::fs::write(path, contents).await
454 }
455
456 async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<DirEntry>> {
457 let mut read_dir = tokio::fs::read_dir(path).await?;
458 let mut entries = Vec::new();
459 while let Some(entry) = read_dir.next_entry().await? {
460 let name = entry.file_name().to_string_lossy().to_string();
461 let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
462 entries.push(DirEntry { name, is_dir });
463 }
464 Ok(entries)
465 }
466
467 async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
468 tokio::fs::create_dir_all(path).await
469 }
470
471 async fn exec_shell(
472 &self,
473 command: &str,
474 cwd: &Path,
475 env: &[(String, String)],
476 timeout: Duration,
477 max_output_bytes: usize,
478 ) -> std::io::Result<ExecResult> {
479 let mut cmd = Command::new("/bin/sh");
480 cmd.arg("-c").arg(command);
481 cmd.current_dir(cwd);
482 cmd.stdout(Stdio::piped());
483 cmd.stderr(Stdio::piped());
484
485 for (key, val) in env {
486 cmd.env(key, val);
487 }
488
489 let mut child = cmd.spawn()?;
490
491 let mut stdout = child.stdout.take().ok_or_else(|| {
492 std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stdout pipe not available")
493 })?;
494 let mut stderr = child.stderr.take().ok_or_else(|| {
495 std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stderr pipe not available")
496 })?;
497
498 let max = max_output_bytes;
499 let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
500 let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
501
502 let wait = child.wait();
503 let status = match tokio::time::timeout(timeout, wait).await {
504 Ok(s) => s?,
505 Err(_) => {
506 return Err(std::io::Error::new(
507 std::io::ErrorKind::TimedOut,
508 format!("command timed out after {:?}", timeout),
509 ));
510 }
511 };
512
513 let out = stdout_task.await.unwrap_or_default();
514 let err = stderr_task.await.unwrap_or_default();
515 let code = status.code();
516
517 Ok(ExecResult {
518 exit_code: code,
519 stdout: out,
520 stderr: err,
521 })
522 }
523}
524
525async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
530 let mut buf = Vec::with_capacity(8 * 1024);
531 let mut tmp = [0u8; 8 * 1024];
532 loop {
533 match reader.read(&mut tmp).await {
534 Ok(0) => break,
535 Ok(n) => {
536 if buf.len() + n > max {
537 let take = max.saturating_sub(buf.len());
538 buf.extend_from_slice(&tmp[..take]);
539 buf.extend_from_slice(b"\n... [output truncated]");
540 let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
541 break;
542 }
543 buf.extend_from_slice(&tmp[..n]);
544 }
545 Err(_) => break,
546 }
547 }
548 String::from_utf8_lossy(&buf).into_owned()
549}
550
551fn parse_host(host: &str) -> (String, Option<u16>) {
554 if let Some(at_pos) = host.rfind('@') {
556 let after_at = &host[at_pos + 1..];
557 if let Some(colon_pos) = after_at.rfind(':') {
558 let host_part = format!("{}@{}", &host[..at_pos], &after_at[..colon_pos]);
559 let port: u16 = after_at[colon_pos + 1..].parse().unwrap_or(22);
560 return (host_part, Some(port));
561 }
562 } else {
563 if let Some(colon_pos) = host.rfind(':') {
565 let host_part = host[..colon_pos].to_string();
566 let port: u16 = host[colon_pos + 1..].parse().unwrap_or(22);
567 return (host_part, Some(port));
568 }
569 }
570 (host.to_string(), None)
571}
572
573fn shell_escape(s: &str) -> String {
576 let escaped = s.replace('\'', "'\\''");
577 format!("'{}'", escaped)
578}
579
580fn base64_encode(bytes: &[u8]) -> String {
582 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
583 let mut result = String::new();
584 for chunk in bytes.chunks(3) {
585 let b0 = chunk[0] as u32;
586 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
587 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
588 let triple = (b0 << 16) | (b1 << 8) | b2;
589
590 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
591 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
592
593 if chunk.len() > 1 {
594 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
595 } else {
596 result.push('=');
597 }
598
599 if chunk.len() > 2 {
600 result.push(CHARS[(triple & 0x3F) as usize] as char);
601 } else {
602 result.push('=');
603 }
604 }
605 result
606}
607
608#[cfg(test)]
613#[cfg(not(target_os = "windows"))]
614mod tests {
615 use super::*;
616 use tempfile::TempDir;
617
618 #[tokio::test]
621 async fn local_transport_read_write_roundtrip() {
622 let t = LocalTransport;
623 let tmp = TempDir::new().unwrap();
624 let path = tmp.path().join("hello.txt");
625
626 t.write_file(&path, b"world").await.unwrap();
627 let data = t.read_file(&path).await.unwrap();
628 assert_eq!(data, b"world");
629 }
630
631 #[tokio::test]
632 async fn local_transport_list_dir() {
633 let t = LocalTransport;
634 let tmp = TempDir::new().unwrap();
635 std::fs::write(tmp.path().join("a.txt"), "x").unwrap();
636 std::fs::create_dir(tmp.path().join("sub")).unwrap();
637
638 let entries = t.list_dir(tmp.path()).await.unwrap();
639 let mut names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
640 names.sort();
641 assert_eq!(names, vec!["a.txt", "sub"]);
642 assert!(!entries.iter().any(|e| e.name == "a.txt" && e.is_dir));
643 assert!(entries.iter().any(|e| e.name == "sub" && e.is_dir));
644 }
645
646 #[tokio::test]
647 async fn local_transport_exec_shell() {
648 let t = LocalTransport;
649 let tmp = TempDir::new().unwrap();
650 let result = t
651 .exec_shell(
652 "echo hello",
653 tmp.path(),
654 &[],
655 Duration::from_secs(5),
656 128 * 1024,
657 )
658 .await
659 .unwrap();
660 assert_eq!(result.exit_code, Some(0));
661 assert!(result.stdout.contains("hello"));
662 }
663
664 #[tokio::test]
665 async fn local_transport_exec_shell_with_env() {
666 let t = LocalTransport;
667 let tmp = TempDir::new().unwrap();
668 let result = t
669 .exec_shell(
670 "echo $MY_VAR",
671 tmp.path(),
672 &[("MY_VAR".into(), "test_value".into())],
673 Duration::from_secs(5),
674 128 * 1024,
675 )
676 .await
677 .unwrap();
678 assert_eq!(result.exit_code, Some(0));
679 assert!(result.stdout.contains("test_value"));
680 }
681
682 #[tokio::test]
683 async fn local_transport_exec_shell_timeout() {
684 let t = LocalTransport;
685 let tmp = TempDir::new().unwrap();
686 let result = t
687 .exec_shell(
688 "sleep 10",
689 tmp.path(),
690 &[],
691 Duration::from_millis(100),
692 128 * 1024,
693 )
694 .await;
695 assert!(result.is_err());
696 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
697 }
698
699 #[tokio::test]
711 async fn local_transport_create_dir_all() {
712 let t = LocalTransport;
713 let tmp = TempDir::new().unwrap();
714 let path = tmp.path().join("a").join("b").join("c");
715 t.create_dir_all(&path).await.unwrap();
716 assert!(path.exists());
717 assert!(path.is_dir());
718 }
719
720 #[test]
723 fn ssh_transport_constructor() {
724 let t = SshTransport::new("user@host", "/remote/workspace");
725 assert_eq!(t.host(), "user@host");
726 assert_eq!(t.remote_workspace(), Path::new("/remote/workspace"));
727 assert!(t.key_path().is_none());
728 }
729
730 #[test]
731 fn ssh_transport_with_key() {
732 let t = SshTransport::new("user@host", "/remote/workspace").with_key("/path/to/key");
733 assert_eq!(t.key_path(), Some(Path::new("/path/to/key")));
734 }
735
736 #[test]
737 fn ssh_transport_with_timeouts() {
738 let t = SshTransport::new("user@host", "/remote/workspace")
739 .with_connect_timeout(Duration::from_secs(30))
740 .with_command_timeout(Duration::from_secs(600));
741 assert_eq!(t.host(), "user@host");
744 }
745
746 #[test]
747 fn ssh_build_command_basic() {
748 let t = SshTransport::new("user@host", "/remote/workspace");
749 let cmd = t.build_ssh_command("ls -la");
750 let args: Vec<String> = cmd
751 .as_std()
752 .get_args()
753 .map(|s| s.to_string_lossy().to_string())
754 .collect();
755
756 assert!(args.contains(&"user@host".to_string()));
758 assert!(args.contains(&"ls -la".to_string()));
759 assert!(args.contains(&"-o".to_string()));
761 assert!(args.contains(&"BatchMode=yes".to_string()));
762 assert!(!args.contains(&"StrictHostKeyChecking=no".to_string()));
764 }
765
766 #[test]
767 fn ssh_build_command_with_key() {
768 let t =
769 SshTransport::new("user@host", "/remote/workspace").with_key("/home/user/.ssh/id_rsa");
770 let cmd = t.build_ssh_command("echo test");
771 let args: Vec<String> = cmd
772 .as_std()
773 .get_args()
774 .map(|s| s.to_string_lossy().to_string())
775 .collect();
776
777 assert!(args.contains(&"-i".to_string()));
778 assert!(args.contains(&"/home/user/.ssh/id_rsa".to_string()));
779 }
780
781 #[test]
782 fn ssh_build_command_with_port() {
783 let t = SshTransport::new("user@host:2222", "/remote/workspace");
784 let cmd = t.build_ssh_command("whoami");
785 let args: Vec<String> = cmd
786 .as_std()
787 .get_args()
788 .map(|s| s.to_string_lossy().to_string())
789 .collect();
790
791 assert!(args.contains(&"-p".to_string()));
792 assert!(args.contains(&"2222".to_string()));
793 assert!(args.contains(&"user@host".to_string()));
794 }
795
796 #[test]
797 fn ssh_build_command_without_user() {
798 let t = SshTransport::new("remote-server:2222", "/remote/workspace");
800 let cmd = t.build_ssh_command("whoami");
801 let args: Vec<String> = cmd
802 .as_std()
803 .get_args()
804 .map(|s| s.to_string_lossy().to_string())
805 .collect();
806
807 assert!(args.contains(&"-p".to_string()));
808 assert!(args.contains(&"2222".to_string()));
809 assert!(args.contains(&"remote-server".to_string()));
810 }
811
812 #[test]
813 fn parse_host_user_at_host() {
814 let (host, port) = parse_host("user@host");
815 assert_eq!(host, "user@host");
816 assert_eq!(port, None);
817 }
818
819 #[test]
820 fn parse_host_user_at_host_port() {
821 let (host, port) = parse_host("user@host:2222");
822 assert_eq!(host, "user@host");
823 assert_eq!(port, Some(2222));
824 }
825
826 #[test]
827 fn parse_host_just_host() {
828 let (host, port) = parse_host("remote-server");
829 assert_eq!(host, "remote-server");
830 assert_eq!(port, None);
831 }
832
833 #[test]
834 fn parse_host_host_with_port_no_user() {
835 let (host, port) = parse_host("remote-server:2222");
836 assert_eq!(host, "remote-server");
837 assert_eq!(port, Some(2222));
838 }
839
840 #[test]
841 fn parse_host_invalid_port_defaults_to_none() {
842 let (host, port) = parse_host("user@host:notanumber");
844 assert_eq!(host, "user@host");
846 assert_eq!(port, Some(22));
847 }
848
849 #[test]
850 fn shell_escape_simple() {
851 assert_eq!(shell_escape("hello"), "'hello'");
852 }
853
854 #[test]
855 fn shell_escape_with_single_quote() {
856 assert_eq!(shell_escape("it's"), "'it'\\''s'");
857 }
858
859 #[test]
860 fn shell_escape_with_spaces() {
861 assert_eq!(
862 shell_escape("/home/user/my project"),
863 "'/home/user/my project'"
864 );
865 }
866
867 #[test]
868 fn shell_escape_empty() {
869 assert_eq!(shell_escape(""), "''");
870 }
871
872 #[test]
873 fn base64_encode_empty() {
874 assert_eq!(base64_encode(b""), "");
875 }
876
877 #[test]
878 fn base64_encode_hello() {
879 assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
881 }
882
883 #[test]
884 fn base64_encode_three_bytes() {
885 assert_eq!(base64_encode(b"abc"), "YWJj");
887 }
888
889 #[test]
890 fn base64_encode_binary() {
891 let bytes = vec![0x00, 0xFF, 0xFE, 0x7F];
892 let encoded = base64_encode(&bytes);
893 assert!(!encoded.is_empty());
894 assert!(encoded.len() % 4 == 0);
896 }
897
898 #[test]
899 fn ssh_transport_is_send_sync() {
900 fn assert_send_sync<T: Send + Sync>() {}
902 assert_send_sync::<SshTransport>();
903 }
904
905 #[test]
906 fn ssh_transport_debug() {
907 let t = SshTransport::new("user@host", "/remote/workspace");
908 let debug = format!("{:?}", t);
909 assert!(debug.contains("SshTransport"));
910 assert!(debug.contains("user@host"));
911 }
912
913 #[test]
914 fn ssh_transport_clone() {
915 let t = SshTransport::new("user@host", "/remote/workspace").with_key("/path/to/key");
916 let t2 = t.clone();
917 assert_eq!(t.host(), t2.host());
918 assert_eq!(t.key_path(), t2.key_path());
919 assert_eq!(t.remote_workspace(), t2.remote_workspace());
920 }
921
922 #[test]
923 fn ssh_transport_implements_tool_transport() {
924 fn assert_tool_transport<T: ToolTransport>() {}
926 assert_tool_transport::<SshTransport>();
927 }
928
929 #[test]
930 fn local_transport_implements_tool_transport() {
931 fn assert_tool_transport<T: ToolTransport>() {}
932 assert_tool_transport::<LocalTransport>();
933 }
934
935 #[test]
937 fn ssh_build_command_connect_timeout() {
938 let t = SshTransport::new("user@host", "/remote/workspace")
939 .with_connect_timeout(Duration::from_secs(15));
940 let cmd = t.build_ssh_command("echo test");
941 let args: Vec<String> = cmd
942 .as_std()
943 .get_args()
944 .map(|s| s.to_string_lossy().to_string())
945 .collect();
946
947 assert!(args.contains(&"ConnectTimeout=15".to_string()));
948 }
949
950 #[test]
953 fn ssh_build_command_known_hosts_secure_by_default() {
954 let t = SshTransport::new("user@host", "/remote/workspace");
955 let cmd = t.build_ssh_command("echo test");
956 let args: Vec<String> = cmd
957 .as_std()
958 .get_args()
959 .map(|s| s.to_string_lossy().to_string())
960 .collect();
961
962 assert!(!args.contains(&"UserKnownHostsFile=/dev/null".to_string()));
963 }
964
965 #[test]
967 fn default_ssh_command_does_not_disable_host_key_checking() {
968 let t = SshTransport::new("user@host", "/remote/workspace");
969 let cmd = t.build_ssh_command("echo test");
970 let args: Vec<String> = cmd
971 .as_std()
972 .get_args()
973 .map(|s| s.to_string_lossy().to_string())
974 .collect();
975
976 assert!(!args.contains(&"StrictHostKeyChecking=no".to_string()));
977 assert!(!args.contains(&"UserKnownHostsFile=/dev/null".to_string()));
978 assert!(args.contains(&"BatchMode=yes".to_string()));
979 }
980
981 #[test]
984 fn insecure_opt_in_enables_host_key_checking_bypass() {
985 let t = SshTransport::new("user@host", "/remote/workspace")
986 .with_insecure_host_key_checking(true);
987 let cmd = t.build_ssh_command("echo test");
988 let args: Vec<String> = cmd
989 .as_std()
990 .get_args()
991 .map(|s| s.to_string_lossy().to_string())
992 .collect();
993
994 assert!(args.contains(&"StrictHostKeyChecking=accept-new".to_string()));
995 assert!(!args.contains(&"StrictHostKeyChecking=no".to_string()));
996 assert!(!args.contains(&"UserKnownHostsFile=/dev/null".to_string()));
997 }
998
999 #[test]
1001 fn insecure_opt_in_defaults_to_false() {
1002 let t = SshTransport::new("user@host", "/remote/workspace");
1003 assert!(!t.insecure_host_key_checking);
1004 let t = t.with_insecure_host_key_checking(true);
1005 assert!(t.insecure_host_key_checking);
1006 let t = t.with_insecure_host_key_checking(false);
1007 assert!(!t.insecure_host_key_checking);
1008 }
1009}