Skip to main content

recursive/tools/
transport.rs

1//! Transport abstraction: decouple tools from direct filesystem/shell access.
2//!
3//! The `ToolTransport` trait lets tools delegate I/O to a pluggable backend.
4//! The default `LocalTransport` calls `tokio::fs` / `tokio::process` directly.
5//! A mock transport can be injected in tests to avoid touching the real disk.
6//!
7//! # SSH Transport
8//!
9//! `SshTransport` executes commands and file operations on a remote host
10//! via the system `ssh` binary. No Rust SSH library needed — delegates to
11//! the installed OpenSSH client.
12//!
13//! Host format: `user@host` or `user@host:port`.
14
15use 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/// Result of reading a file.
24#[derive(Debug, Clone)]
25pub struct ReadResult {
26    pub bytes: Vec<u8>,
27}
28
29/// Result of listing a directory entry.
30#[derive(Debug, Clone)]
31pub struct DirEntry {
32    pub name: String,
33    pub is_dir: bool,
34}
35
36/// Result of running a shell command.
37#[derive(Debug, Clone)]
38pub struct ExecResult {
39    pub exit_code: Option<i32>,
40    pub stdout: String,
41    pub stderr: String,
42}
43
44/// Abstract transport for filesystem and shell operations.
45///
46/// Tools that need I/O (`ReadFile`, `WriteFile`, `ListDir`, `RunShell`)
47/// call methods on this trait instead of using `tokio::fs` / `tokio::process`
48/// directly. This makes them testable without touching the real filesystem.
49#[async_trait]
50pub trait ToolTransport: Send + Sync + std::fmt::Debug {
51    /// Read the full contents of a file at `path`.
52    async fn read_file(&self, path: &Path) -> std::io::Result<Vec<u8>>;
53
54    /// Write `contents` to a file at `path`, creating parent directories.
55    async fn write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()>;
56
57    /// List entries in a directory at `path`.
58    async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<DirEntry>>;
59
60    /// Create a directory and all parents.
61    async fn create_dir_all(&self, path: &Path) -> std::io::Result<()>;
62
63    /// Execute a shell command in the given working directory with optional
64    /// environment variables, timeout, and max output bytes.
65    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// ---------------------------------------------------------------------------
76// SSH Transport
77// ---------------------------------------------------------------------------
78
79/// SSH transport: executes commands and file operations on a remote host
80/// via the system `ssh` binary.
81///
82/// # Host format
83///
84/// - `user@host` — default SSH port (22)
85/// - `user@host:port` — custom port
86///
87/// # Auth
88///
89/// By default uses `ssh-agent` or `~/.ssh/id_*` keys. Optionally specify
90/// a private key path via `key_path`.
91#[derive(Debug, Clone)]
92pub struct SshTransport {
93    /// SSH connection string: user@host or user@host:port
94    host: String,
95    /// Path to private key (optional, defaults to ssh-agent)
96    key_path: Option<PathBuf>,
97    /// Remote workspace directory
98    remote_workspace: PathBuf,
99    /// SSH connect timeout
100    connect_timeout: Duration,
101    /// SSH command timeout
102    command_timeout: Duration,
103    /// When `true`, skip strict host key verification for ephemeral / throwaway
104    /// hosts. MITM-unsafe: only for trusted networks or disposable sandboxes.
105    /// Default `false` (secure).
106    insecure_host_key_checking: bool,
107}
108
109impl SshTransport {
110    /// Create a new SSH transport.
111    ///
112    /// `host` is in `user@host` or `user@host:port` format.
113    /// `remote_workspace` is the absolute path to the workspace on the remote machine.
114    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    /// Set the path to an SSH private key.
126    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    /// Set the SSH connect timeout.
132    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
133        self.connect_timeout = timeout;
134        self
135    }
136
137    /// Set the SSH command timeout.
138    pub fn with_command_timeout(mut self, timeout: Duration) -> Self {
139        self.command_timeout = timeout;
140        self
141    }
142
143    /// Opt in to skipping host-key verification (MITM-unsafe).
144    /// Emits a `tracing::warn!` so the choice is visible in logs.
145    ///
146    /// When enabled, commands are built with `StrictHostKeyChecking=accept-new`
147    /// (first-use trust): the remote host key is accepted on first connection
148    /// but later changes are still detected. This is strictly better than
149    /// `StrictHostKeyChecking=no` but is still unsafe against an active MITM
150    /// on the very first connection — only use it for trusted networks or
151    /// disposable sandboxes. Default is `false` (secure).
152    pub fn with_insecure_host_key_checking(mut self, on: bool) -> Self {
153        self.insecure_host_key_checking = on;
154        self
155    }
156
157    /// Return the host string.
158    pub fn host(&self) -> &str {
159        &self.host
160    }
161
162    /// Return the key path, if set.
163    pub fn key_path(&self) -> Option<&Path> {
164        self.key_path.as_deref()
165    }
166
167    /// Return the remote workspace path.
168    pub fn remote_workspace(&self) -> &Path {
169        &self.remote_workspace
170    }
171
172    /// Build a `tokio::process::Command` for an SSH invocation.
173    fn build_ssh_command(&self, remote_command: &str) -> Command {
174        let mut cmd = Command::new("ssh");
175
176        // Non-interactive options
177        cmd.arg("-o").arg("BatchMode=yes");
178        cmd.arg("-o")
179            .arg(format!("ConnectTimeout={}", self.connect_timeout.as_secs()));
180
181        // Host key verification is secure by default: use the user's
182        // known_hosts. Only an explicit opt-in relaxes it for ephemeral hosts.
183        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        // Optional identity file
193        if let Some(ref key) = self.key_path {
194            cmd.arg("-i").arg(key);
195        }
196
197        // Parse host:port format
198        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    /// Execute a command via SSH and capture stdout/stderr.
213    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                // Kill the child so the SSH connection drops and the reader
232                // tasks unblock (the remote command's pipes close when the
233                // ssh client dies); then drain them so their JoinHandles
234                // don't detach with buffered output. Order matters: the
235                // readers block on the pipes until the child is dead, so
236                // kill/wait BEFORE awaiting them. Best-effort cleanup on
237                // the error path — swallow secondary failures from it.
238                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    /// Write content to a remote file by piping base64 over SSH.
261    async fn ssh_write_file(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
262        // First ensure the parent directory exists
263        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        // Write file content via base64 to avoid escaping issues
278        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            // Check if it's a directory via a separate SSH call
338            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        // Build the remote command: cd to workspace, set env vars, run command
378        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                // Same kill-then-drain cleanup as `ssh_exec`: the ssh
408                // client must die before the reader tasks can unblock,
409                // and awaiting them here prevents their JoinHandles from
410                // detaching with buffered output.
411                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// ---------------------------------------------------------------------------
435// Local Transport
436// ---------------------------------------------------------------------------
437
438/// The default transport that performs real I/O via `tokio::fs` and
439/// `tokio::process`.
440#[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
525// ---------------------------------------------------------------------------
526// Helpers
527// ---------------------------------------------------------------------------
528
529async 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
551/// Parse a host string of the form `user@host` or `user@host:port`.
552/// Returns `(host_string, optional_port)`.
553fn parse_host(host: &str) -> (String, Option<u16>) {
554    // Split off port if present (last colon after @)
555    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        // No @ — just host or host:port
564        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
573/// Shell-escape a string for safe use in SSH commands.
574/// Wraps in single quotes and escapes any single quotes inside.
575fn shell_escape(s: &str) -> String {
576    let escaped = s.replace('\'', "'\\''");
577    format!("'{}'", escaped)
578}
579
580/// Base64-encode bytes (simple implementation without external crate).
581fn 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// ---------------------------------------------------------------------------
609// Tests
610// ---------------------------------------------------------------------------
611
612#[cfg(test)]
613#[cfg(not(target_os = "windows"))]
614mod tests {
615    use super::*;
616    use tempfile::TempDir;
617
618    // --- Local transport tests (unchanged) ---
619
620    #[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    // TODO(goal-360): the SSH timeout arms in `ssh_exec` and `exec_shell`
700    // now kill the child and drain both reader tasks before returning,
701    // but that behavior can't be exercised end-to-end here without an SSH
702    // server in the test environment — spawning `ssh user@host` against a
703    // missing server fails fast with connection refused and never reaches
704    // the timeout branch. The kill-then-drain contract is pinned by
705    // `shell_timeout_drains_reader_tasks` in shell.rs (same spawn → two
706    // reader tasks → timeout(child.wait()) structure) and is
707    // grep-verifiable in this file: the timeout `Err(_)` arms contain
708    // `start_kill` and `stdout_task.await`.
709
710    #[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    // --- SSH transport tests (no actual SSH required) ---
721
722    #[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        // We can't inspect private fields directly, but we can verify
742        // the builder pattern compiles and returns the right type.
743        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        // Should include options, host, and command
757        assert!(args.contains(&"user@host".to_string()));
758        assert!(args.contains(&"ls -la".to_string()));
759        // Should have BatchMode=yes
760        assert!(args.contains(&"-o".to_string()));
761        assert!(args.contains(&"BatchMode=yes".to_string()));
762        // Default must NOT disable host key verification
763        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        // Host without @ — just a hostname
799        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        // Invalid port number — should return None for port
843        let (host, port) = parse_host("user@host:notanumber");
844        // Invalid port defaults to 22
845        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        // "hello" in base64 is "aGVsbG8="
880        assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
881    }
882
883    #[test]
884    fn base64_encode_three_bytes() {
885        // "abc" in base64 is "YWJj"
886        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        // Decode check: should be valid base64
895        assert!(encoded.len() % 4 == 0);
896    }
897
898    #[test]
899    fn ssh_transport_is_send_sync() {
900        // Compile-time check: SshTransport must implement Send + Sync
901        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        // Compile-time check: SshTransport must implement ToolTransport
925        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 that the SSH command construction includes ConnectTimeout.
936    #[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 that the default SSH command does NOT discard known_hosts
951    /// verification (previously it appended UserKnownHostsFile=/dev/null).
952    #[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    /// The default SSH command must keep host key verification enabled.
966    #[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    /// Opting in to insecure host key checking relaxes verification via
982    /// `StrictHostKeyChecking=accept-new` (first-use trust).
983    #[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    /// A fresh transport must default to secure host key checking.
1000    #[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}