Skip to main content

gritty/
connect.rs

1use anyhow::{Context, bail};
2use std::os::fd::OwnedFd;
3use std::os::unix::fs::OpenOptionsExt;
4use std::os::unix::io::{AsRawFd, FromRawFd};
5use std::path::{Path, PathBuf};
6use std::process::Stdio;
7use std::time::{Duration, Instant};
8use tokio::process::{Child, Command};
9use tracing::{debug, info, warn};
10
11// ---------------------------------------------------------------------------
12// Destination parsing
13// ---------------------------------------------------------------------------
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16struct Destination {
17    user: Option<String>,
18    host: String,
19    port: Option<u16>,
20}
21
22impl Destination {
23    fn parse(s: &str) -> anyhow::Result<Self> {
24        if s.is_empty() {
25            bail!("empty destination");
26        }
27
28        let (user, remainder) = if let Some(at) = s.find('@') {
29            let u = &s[..at];
30            if u.is_empty() {
31                bail!("empty user in destination: {s}");
32            }
33            (Some(u.to_string()), &s[at + 1..])
34        } else {
35            (None, s)
36        };
37
38        let (host, port) = if let Some(colon) = remainder.rfind(':') {
39            let h = &remainder[..colon];
40            let p = remainder[colon + 1..]
41                .parse::<u16>()
42                .with_context(|| format!("invalid port in destination: {s}"))?;
43            (h.to_string(), Some(p))
44        } else {
45            (remainder.to_string(), None)
46        };
47
48        if host.is_empty() {
49            bail!("empty host in destination: {s}");
50        }
51
52        Ok(Self { user, host, port })
53    }
54
55    /// Build the SSH destination string (`user@host` or just `host`).
56    fn ssh_dest(&self) -> String {
57        match &self.user {
58            Some(u) => format!("{u}@{}", self.host),
59            None => self.host.clone(),
60        }
61    }
62
63    /// Common SSH args for port, if set.
64    fn port_args(&self) -> Vec<String> {
65        match self.port {
66            Some(p) => vec!["-p".to_string(), p.to_string()],
67            None => vec![],
68        }
69    }
70}
71
72// ---------------------------------------------------------------------------
73// SSH helpers
74// ---------------------------------------------------------------------------
75
76/// Hardened SSH options embedded in every tunnel.
77const SSH_TUNNEL_OPTS: &[&str] = &[
78    "-o",
79    "ServerAliveInterval=3",
80    "-o",
81    "ServerAliveCountMax=2",
82    "-o",
83    "StreamLocalBindUnlink=yes",
84    "-o",
85    "ExitOnForwardFailure=yes",
86    "-o",
87    "ConnectTimeout=5",
88    "-N",
89    "-T",
90];
91
92/// PATH prefix prepended to remote commands so gritty is discoverable
93/// in non-interactive SSH shells.
94const REMOTE_PATH_PREFIX: &str = "$HOME/bin:$HOME/.local/bin:$HOME/.cargo/bin:$PATH";
95
96/// Build the SSH command for remote execution (without stdio config).
97fn remote_exec_command(dest: &Destination, remote_cmd: &str, extra_ssh_opts: &[String]) -> Command {
98    let wrapped_cmd = format!("PATH=\"{REMOTE_PATH_PREFIX}\"; {remote_cmd}");
99    let mut cmd = Command::new("ssh");
100    cmd.args(dest.port_args());
101    for opt in extra_ssh_opts {
102        cmd.arg("-o").arg(opt);
103    }
104    cmd.arg("-o").arg("ConnectTimeout=5");
105    cmd.arg(dest.ssh_dest());
106    cmd.arg(&wrapped_cmd);
107    cmd
108}
109
110/// Run a command on the remote host via SSH, returning stdout.
111async fn remote_exec(
112    dest: &Destination,
113    remote_cmd: &str,
114    extra_ssh_opts: &[String],
115) -> anyhow::Result<String> {
116    debug!("ssh {}: {remote_cmd}", dest.ssh_dest());
117
118    let mut cmd = remote_exec_command(dest, remote_cmd, extra_ssh_opts);
119    cmd.stdout(Stdio::piped());
120    cmd.stderr(Stdio::piped());
121    cmd.stdin(Stdio::null());
122
123    let output = cmd.output().await.context("failed to run ssh")?;
124
125    if !output.status.success() {
126        let stderr = String::from_utf8_lossy(&output.stderr);
127        let stderr = stderr.trim();
128        debug!("ssh failed (status {}): {stderr}", output.status);
129        if stderr.contains("command not found") || stderr.contains("No such file") {
130            bail!("gritty not found on remote host (is it in PATH?)");
131        }
132        bail!("ssh command failed: {stderr}");
133    }
134
135    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
136    debug!("ssh output: {stdout}");
137    Ok(stdout)
138}
139
140/// Shell-quote a string if it contains characters that need quoting.
141/// Used only for display (--dry-run output), never for command execution.
142fn shell_quote(s: &str) -> String {
143    if s.is_empty() {
144        return "''".to_string();
145    }
146    if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_./=:@$+%,".contains(&b)) {
147        return s.to_string();
148    }
149    format!("'{}'", s.replace('\'', "'\\''"))
150}
151
152/// Format a tokio Command as a shell string for display.
153fn format_command(cmd: &Command) -> String {
154    let std_cmd = cmd.as_std();
155    let prog = std_cmd.get_program().to_string_lossy();
156    let args: Vec<_> = std_cmd.get_args().map(|a| shell_quote(&a.to_string_lossy())).collect();
157    if args.is_empty() { prog.to_string() } else { format!("{prog} {}", args.join(" ")) }
158}
159
160/// Build the SSH tunnel command with hardened options.
161fn tunnel_command(
162    dest: &Destination,
163    local_sock: &Path,
164    remote_sock: &str,
165    extra_ssh_opts: &[String],
166) -> Command {
167    let mut cmd = Command::new("ssh");
168    cmd.args(dest.port_args());
169    cmd.args(SSH_TUNNEL_OPTS);
170    for opt in extra_ssh_opts {
171        cmd.arg("-o").arg(opt);
172    }
173    let forward = format!("{}:{}", local_sock.display(), remote_sock);
174    cmd.arg("-L").arg(forward);
175    cmd.arg(dest.ssh_dest());
176    cmd.stdout(Stdio::null());
177    cmd.stderr(Stdio::piped());
178    cmd.stdin(Stdio::null());
179    cmd
180}
181
182/// Spawn the SSH tunnel, returning the child process.
183async fn spawn_tunnel(
184    dest: &Destination,
185    local_sock: &Path,
186    remote_sock: &str,
187    extra_ssh_opts: &[String],
188) -> anyhow::Result<Child> {
189    debug!("tunnel: {} -> {}:{}", local_sock.display(), dest.ssh_dest(), remote_sock,);
190    let mut cmd = tunnel_command(dest, local_sock, remote_sock, extra_ssh_opts);
191    let child = cmd.spawn().context("failed to spawn ssh tunnel")?;
192    debug!("ssh tunnel pid: {:?}", child.id());
193    Ok(child)
194}
195
196/// Poll until the local socket is connectable (200ms interval, 15s timeout).
197async fn wait_for_socket(path: &Path) -> anyhow::Result<()> {
198    let deadline = Instant::now() + Duration::from_secs(15);
199    loop {
200        if std::os::unix::net::UnixStream::connect(path).is_ok() {
201            return Ok(());
202        }
203        if Instant::now() >= deadline {
204            bail!("timeout waiting for SSH tunnel socket at {}", path.display());
205        }
206        tokio::time::sleep(Duration::from_millis(200)).await;
207    }
208}
209
210/// Background task: monitor SSH child, respawn on transient failure.
211async fn tunnel_monitor(
212    mut child: Child,
213    dest: Destination,
214    local_sock: PathBuf,
215    remote_sock: String,
216    extra_ssh_opts: Vec<String>,
217    stop: tokio_util::sync::CancellationToken,
218) {
219    let mut exit_times: Vec<Instant> = Vec::new();
220
221    loop {
222        tokio::select! {
223            _ = stop.cancelled() => {
224                let _ = child.kill().await;
225                return;
226            }
227            status = child.wait() => {
228                let status = match status {
229                    Ok(s) => s,
230                    Err(e) => {
231                        warn!("failed to wait on ssh tunnel: {e}");
232                        return;
233                    }
234                };
235
236                if stop.is_cancelled() {
237                    return;
238                }
239
240                let code = status.code();
241                debug!("ssh tunnel exited: {:?}", code);
242
243                // Non-transient failure: don't retry
244                // SSH exit 255 = connection error (transient). Signal-killed = no code.
245                // Everything else (auth failure, config error) = bail.
246                if let Some(c) = code
247                    && c != 255
248                {
249                    warn!("ssh tunnel exited with code {c} (not retrying)");
250                    return;
251                }
252
253                // Rate limit: 5 exits in 10s = give up
254                let now = Instant::now();
255                exit_times.push(now);
256                exit_times.retain(|t| now.duration_since(*t) < Duration::from_secs(10));
257                if exit_times.len() >= 5 {
258                    warn!("ssh tunnel failing too fast (5 exits in 10s), giving up");
259                    return;
260                }
261
262                tokio::time::sleep(Duration::from_secs(1)).await;
263
264                if stop.is_cancelled() {
265                    return;
266                }
267
268                match spawn_tunnel(&dest, &local_sock, &remote_sock, &extra_ssh_opts).await {
269                    Ok(new_child) => {
270                        info!("ssh tunnel respawned");
271                        child = new_child;
272                    }
273                    Err(e) => {
274                        warn!("failed to respawn ssh tunnel: {e}");
275                        return;
276                    }
277                }
278            }
279        }
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Remote server management
285// ---------------------------------------------------------------------------
286
287const REMOTE_ENSURE_CMD: &str = "\
288    SOCK=$(gritty socket-path) && \
289    (gritty ls >/dev/null 2>&1 || \
290     { gritty server && sleep 0.3; }) && \
291    echo \"$SOCK\"";
292
293/// Get the remote socket path and optionally auto-start the server.
294async fn ensure_remote_ready(
295    dest: &Destination,
296    no_server_start: bool,
297    extra_ssh_opts: &[String],
298) -> anyhow::Result<String> {
299    let remote_cmd = if no_server_start { "gritty socket-path" } else { REMOTE_ENSURE_CMD };
300    debug!("ensuring remote server (no_server_start={no_server_start})");
301
302    let sock_path = remote_exec(dest, remote_cmd, extra_ssh_opts).await?;
303
304    if sock_path.is_empty() {
305        bail!("remote host returned empty socket path");
306    }
307
308    Ok(sock_path)
309}
310
311// ---------------------------------------------------------------------------
312// Local socket path
313// ---------------------------------------------------------------------------
314
315/// Compute a deterministic local socket path based on the destination.
316///
317/// Using the raw destination string means re-running `gritty connect user@host`
318/// produces the same socket path, so sessions that used `--ctl-socket` can
319/// auto-reconnect after a tunnel restart.
320fn local_socket_path(destination: &str) -> PathBuf {
321    crate::daemon::socket_dir().join(format!("connect-{destination}.sock"))
322}
323
324fn connect_pid_path(connection_name: &str) -> PathBuf {
325    crate::daemon::socket_dir().join(format!("connect-{connection_name}.pid"))
326}
327
328fn connect_lock_path(connection_name: &str) -> PathBuf {
329    crate::daemon::socket_dir().join(format!("connect-{connection_name}.lock"))
330}
331
332fn connect_dest_path(connection_name: &str) -> PathBuf {
333    crate::daemon::socket_dir().join(format!("connect-{connection_name}.dest"))
334}
335
336/// Compute the local socket path for a given connection name.
337/// Public so main.rs can compute the path in the parent process after daemonize.
338pub fn connection_socket_path(connection_name: &str) -> PathBuf {
339    local_socket_path(connection_name)
340}
341
342// ---------------------------------------------------------------------------
343// Lockfile-based liveness
344// ---------------------------------------------------------------------------
345
346/// Acquire an exclusive flock on the lockfile. Returns the locked fd on success.
347/// The lock is held for the lifetime of the returned `OwnedFd`.
348fn acquire_lock(lock_path: &Path) -> anyhow::Result<OwnedFd> {
349    use std::fs::OpenOptions;
350    let file = OpenOptions::new()
351        .create(true)
352        .truncate(false)
353        .write(true)
354        .mode(0o600)
355        .open(lock_path)
356        .with_context(|| format!("failed to open lockfile: {}", lock_path.display()))?;
357    let fd = OwnedFd::from(file);
358    if unsafe { libc::flock(fd.as_raw_fd(), libc::LOCK_EX) } != 0 {
359        bail!("failed to acquire lock on {}", lock_path.display());
360    }
361    Ok(fd)
362}
363
364/// Probe whether a lockfile is held by a live process.
365/// Returns true if the lock is held (process alive), false if free (process dead).
366fn is_lock_held(lock_path: &Path) -> bool {
367    use std::fs::OpenOptions;
368    let file = match OpenOptions::new().read(true).open(lock_path) {
369        Ok(f) => f,
370        Err(_) => return false,
371    };
372    // Non-blocking exclusive lock attempt: if it succeeds, the old process is dead
373    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
374        // We got the lock — old process is gone. Release it immediately (fd drop).
375        false
376    } else {
377        true // Lock held by another process
378    }
379}
380
381/// Tunnel health status.
382#[derive(Debug, PartialEq, Eq)]
383pub enum TunnelStatus {
384    Healthy,
385    Reconnecting,
386    Stale,
387}
388
389/// Probe a tunnel's status using lockfile + socket connectivity.
390fn probe_tunnel_status(name: &str) -> TunnelStatus {
391    let lock_path = connect_lock_path(name);
392    if is_lock_held(&lock_path) {
393        let sock_path = local_socket_path(name);
394        if std::os::unix::net::UnixStream::connect(&sock_path).is_ok() {
395            TunnelStatus::Healthy
396        } else {
397            TunnelStatus::Reconnecting
398        }
399    } else {
400        TunnelStatus::Stale
401    }
402}
403
404/// Clean up files for a stale tunnel (process already dead).
405/// Attempts killpg on the old PID to reap orphaned SSH children.
406fn cleanup_stale_files(name: &str) {
407    let pid_file = connect_pid_path(name);
408    if let Ok(contents) = std::fs::read_to_string(&pid_file) {
409        if let Ok(pid) = contents.trim().parse::<i32>() {
410            // Safe: PGID won't be recycled to a different group while files exist
411            unsafe {
412                libc::killpg(pid, libc::SIGTERM);
413            }
414        }
415    }
416    let _ = std::fs::remove_file(local_socket_path(name));
417    let _ = std::fs::remove_file(pid_file);
418    let _ = std::fs::remove_file(connect_lock_path(name));
419    let _ = std::fs::remove_file(connect_dest_path(name));
420}
421
422/// Extract tunnel connection names by globbing lock files in the socket dir.
423fn enumerate_tunnels() -> Vec<String> {
424    let dir = crate::daemon::socket_dir();
425    let Ok(entries) = std::fs::read_dir(&dir) else {
426        return Vec::new();
427    };
428    entries
429        .filter_map(|e| e.ok())
430        .filter_map(|e| {
431            let name = e.file_name().to_string_lossy().to_string();
432            if name.starts_with("connect-") && name.ends_with(".lock") {
433                Some(name["connect-".len()..name.len() - ".lock".len()].to_string())
434            } else {
435                None
436            }
437        })
438        .collect()
439}
440
441// ---------------------------------------------------------------------------
442// Cleanup guard
443// ---------------------------------------------------------------------------
444
445struct ConnectGuard {
446    child: Option<Child>,
447    local_sock: PathBuf,
448    pid_file: PathBuf,
449    lock_file: PathBuf,
450    dest_file: PathBuf,
451    _lock_fd: Option<OwnedFd>,
452    stop: tokio_util::sync::CancellationToken,
453}
454
455impl Drop for ConnectGuard {
456    fn drop(&mut self) {
457        self.stop.cancel();
458
459        if let Some(ref mut child) = self.child
460            && let Some(pid) = child.id()
461        {
462            unsafe {
463                libc::kill(pid as i32, libc::SIGTERM);
464            }
465        }
466
467        let _ = std::fs::remove_file(&self.local_sock);
468        let _ = std::fs::remove_file(&self.pid_file);
469        let _ = std::fs::remove_file(&self.lock_file);
470        let _ = std::fs::remove_file(&self.dest_file);
471        // _lock_fd drops here, releasing the flock
472    }
473}
474
475// ---------------------------------------------------------------------------
476// Public API
477// ---------------------------------------------------------------------------
478
479pub struct ConnectOpts {
480    pub destination: String,
481    pub no_server_start: bool,
482    pub ssh_options: Vec<String>,
483    pub name: Option<String>,
484    pub dry_run: bool,
485}
486
487pub async fn run(opts: ConnectOpts, ready_fd: Option<OwnedFd>) -> anyhow::Result<i32> {
488    let dest = Destination::parse(&opts.destination)?;
489    let connection_name = opts.name.unwrap_or_else(|| dest.host.clone());
490    let local_sock = local_socket_path(&connection_name);
491
492    if opts.dry_run {
493        let remote_cmd =
494            if opts.no_server_start { "gritty socket-path" } else { REMOTE_ENSURE_CMD };
495        let ensure_cmd = remote_exec_command(&dest, remote_cmd, &opts.ssh_options);
496        let tunnel_cmd = tunnel_command(&dest, &local_sock, "$REMOTE_SOCK", &opts.ssh_options);
497
498        println!(
499            "# Get remote socket path{}",
500            if opts.no_server_start { "" } else { " and start server if needed" }
501        );
502        println!("REMOTE_SOCK=$({})", format_command(&ensure_cmd));
503        println!();
504        println!("# Start SSH tunnel");
505        println!("{}", format_command(&tunnel_cmd));
506        return Ok(0);
507    }
508
509    // 1. Ensure socket directory exists
510    let pid_file = connect_pid_path(&connection_name);
511    let lock_path = connect_lock_path(&connection_name);
512    let dest_file = connect_dest_path(&connection_name);
513    debug!("local socket: {}", local_sock.display());
514    if let Some(parent) = local_sock.parent() {
515        crate::security::secure_create_dir_all(parent)?;
516    }
517
518    // 2. Check for existing tunnel via lockfile (authoritative)
519    match probe_tunnel_status(&connection_name) {
520        TunnelStatus::Healthy => {
521            println!("{}", local_sock.display());
522            let pid_hint =
523                std::fs::read_to_string(&pid_file).ok().and_then(|s| s.trim().parse::<u32>().ok());
524            eprint!("tunnel already running (name: {connection_name})");
525            if let Some(pid) = pid_hint {
526                eprintln!(" (pid {pid})");
527                eprintln!("  to stop: gritty disconnect {connection_name}");
528            } else {
529                eprintln!();
530            }
531            eprintln!("  to use:");
532            eprintln!("    gritty new {connection_name}");
533            eprintln!("    gritty attach {connection_name} -t <name>");
534            // Signal readiness to parent even for already-running case
535            signal_ready(&ready_fd);
536            return Ok(0);
537        }
538        TunnelStatus::Reconnecting => {
539            let pid_hint =
540                std::fs::read_to_string(&pid_file).ok().and_then(|s| s.trim().parse::<u32>().ok());
541            eprint!("tunnel exists but is reconnecting (name: {connection_name})");
542            if let Some(pid) = pid_hint {
543                eprintln!(" (pid {pid})");
544            } else {
545                eprintln!();
546            }
547            eprintln!("  wait for it, or: gritty disconnect {connection_name}");
548            // Signal readiness to parent so it doesn't hang
549            signal_ready(&ready_fd);
550            return Ok(0);
551        }
552        TunnelStatus::Stale => {
553            debug!("cleaning stale tunnel files for {connection_name}");
554            cleanup_stale_files(&connection_name);
555        }
556    }
557
558    // 3. Acquire lockfile (held for entire lifetime of this process)
559    let lock_fd = acquire_lock(&lock_path)?;
560
561    // 4. Ensure remote server is running and get socket path
562    let remote_sock = ensure_remote_ready(&dest, opts.no_server_start, &opts.ssh_options).await?;
563    debug!(remote_sock, "remote socket path");
564
565    // 5. Spawn SSH tunnel
566    let child = spawn_tunnel(&dest, &local_sock, &remote_sock, &opts.ssh_options).await?;
567    let stop = tokio_util::sync::CancellationToken::new();
568
569    let mut guard = ConnectGuard {
570        child: Some(child),
571        local_sock: local_sock.clone(),
572        pid_file: pid_file.clone(),
573        lock_file: lock_path,
574        dest_file: dest_file.clone(),
575        _lock_fd: Some(lock_fd),
576        stop: stop.clone(),
577    };
578
579    // 6. Wait for local socket to become connectable
580    wait_for_socket(&local_sock).await?;
581    debug!("tunnel socket ready");
582
583    // Write PID + dest files
584    let _ = std::fs::write(&pid_file, std::process::id().to_string());
585    let _ = std::fs::write(&dest_file, &opts.destination);
586
587    // 7. Signal readiness to parent (or print if foreground)
588    signal_ready(&ready_fd);
589
590    // 8. Hand off the child to the tunnel monitor background task
591    let original_child = guard.child.take().unwrap();
592    let monitor_handle = tokio::spawn(tunnel_monitor(
593        original_child,
594        dest,
595        local_sock.clone(),
596        remote_sock,
597        opts.ssh_options,
598        stop.clone(),
599    ));
600
601    // 9. Wait for signal or monitor death
602    let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
603    tokio::select! {
604        _ = sigterm.recv() => {}
605        _ = monitor_handle => {}
606    }
607
608    // 10. Cleanup (guard Drop handles ssh kill + file removal + lock release)
609    drop(guard);
610
611    Ok(0)
612}
613
614/// Write one readiness byte to the pipe fd (if present).
615fn signal_ready(ready_fd: &Option<OwnedFd>) {
616    if let Some(fd) = ready_fd {
617        use std::io::Write;
618        let mut f = std::io::BufWriter::new(unsafe {
619            // Safety: we're borrowing the fd, not taking ownership
620            std::fs::File::from_raw_fd(fd.as_raw_fd())
621        });
622        let _ = f.write_all(b"\x01");
623        let _ = f.flush();
624        // Don't drop the File — it doesn't own the fd. Leak it.
625        std::mem::forget(f);
626    }
627}
628
629// ---------------------------------------------------------------------------
630// Disconnect
631// ---------------------------------------------------------------------------
632
633pub async fn disconnect(name: &str) -> anyhow::Result<()> {
634    match probe_tunnel_status(name) {
635        TunnelStatus::Stale => {
636            cleanup_stale_files(name);
637            eprintln!("tunnel already stopped: {name}");
638            return Ok(());
639        }
640        TunnelStatus::Healthy | TunnelStatus::Reconnecting => {}
641    }
642
643    // Read PID and send SIGTERM (let the process handle graceful shutdown)
644    let pid_file = connect_pid_path(name);
645    let pid = std::fs::read_to_string(&pid_file)
646        .ok()
647        .and_then(|s| s.trim().parse::<i32>().ok())
648        .ok_or_else(|| anyhow::anyhow!("cannot read PID for tunnel {name}"))?;
649
650    unsafe {
651        libc::kill(pid, libc::SIGTERM);
652    }
653
654    // Poll lock for up to 2s to confirm exit
655    let deadline = Instant::now() + Duration::from_secs(2);
656    loop {
657        tokio::time::sleep(Duration::from_millis(100)).await;
658        if !is_lock_held(&connect_lock_path(name)) {
659            cleanup_stale_files(name);
660            eprintln!("tunnel stopped: {name}");
661            return Ok(());
662        }
663        if Instant::now() >= deadline {
664            break;
665        }
666    }
667
668    // Still alive after timeout — escalate to SIGKILL + killpg
669    unsafe {
670        libc::kill(pid, libc::SIGKILL);
671        libc::killpg(pid, libc::SIGTERM);
672    }
673    tokio::time::sleep(Duration::from_millis(100)).await;
674    cleanup_stale_files(name);
675    eprintln!("tunnel killed: {name}");
676    Ok(())
677}
678
679// ---------------------------------------------------------------------------
680// List tunnels
681// ---------------------------------------------------------------------------
682
683pub fn list_tunnels() {
684    let names = enumerate_tunnels();
685    if names.is_empty() {
686        println!("no active tunnels");
687        return;
688    }
689
690    // Probe each, clean stale ones, collect live entries
691    let mut rows: Vec<(String, String, String)> = Vec::new();
692    for name in &names {
693        let status = probe_tunnel_status(name);
694        if status == TunnelStatus::Stale {
695            debug!("cleaning stale tunnel: {name}");
696            cleanup_stale_files(name);
697            continue;
698        }
699        let dest =
700            std::fs::read_to_string(connect_dest_path(name)).unwrap_or_else(|_| "-".to_string());
701        let status_str = match status {
702            TunnelStatus::Healthy => "healthy".to_string(),
703            TunnelStatus::Reconnecting => "reconnecting".to_string(),
704            TunnelStatus::Stale => unreachable!(),
705        };
706        rows.push((name.clone(), dest.trim().to_string(), status_str));
707    }
708
709    if rows.is_empty() {
710        println!("no active tunnels");
711        return;
712    }
713
714    let w_name = rows.iter().map(|r| r.0.len()).max().unwrap().max(4);
715    let w_dest = rows.iter().map(|r| r.1.len()).max().unwrap().max(11);
716
717    println!("{:<w_name$}  {:<w_dest$}  Status", "Name", "Destination");
718    for (name, dest, status) in &rows {
719        println!("{:<w_name$}  {:<w_dest$}  {status}", name, dest);
720    }
721}
722
723// ---------------------------------------------------------------------------
724// Tests
725// ---------------------------------------------------------------------------
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    #[test]
732    fn parse_destination_user_host() {
733        let d = Destination::parse("user@host").unwrap();
734        assert_eq!(d.user.as_deref(), Some("user"));
735        assert_eq!(d.host, "host");
736        assert_eq!(d.port, None);
737    }
738
739    #[test]
740    fn parse_destination_host_only() {
741        let d = Destination::parse("myhost").unwrap();
742        assert_eq!(d.user, None);
743        assert_eq!(d.host, "myhost");
744        assert_eq!(d.port, None);
745    }
746
747    #[test]
748    fn parse_destination_host_port() {
749        let d = Destination::parse("host:2222").unwrap();
750        assert_eq!(d.user, None);
751        assert_eq!(d.host, "host");
752        assert_eq!(d.port, Some(2222));
753    }
754
755    #[test]
756    fn parse_destination_user_host_port() {
757        let d = Destination::parse("user@host:2222").unwrap();
758        assert_eq!(d.user.as_deref(), Some("user"));
759        assert_eq!(d.host, "host");
760        assert_eq!(d.port, Some(2222));
761    }
762
763    #[test]
764    fn parse_destination_invalid_empty() {
765        assert!(Destination::parse("").is_err());
766    }
767
768    #[test]
769    fn parse_destination_invalid_at_only() {
770        assert!(Destination::parse("@host").is_err());
771    }
772
773    #[test]
774    fn parse_destination_invalid_colon_only() {
775        assert!(Destination::parse(":2222").is_err());
776    }
777
778    #[test]
779    fn tunnel_command_default_opts() {
780        let dest = Destination::parse("user@host").unwrap();
781        let cmd = tunnel_command(
782            &dest,
783            Path::new("/tmp/local.sock"),
784            "/run/user/1000/gritty/ctl.sock",
785            &[],
786        );
787        let args: Vec<_> =
788            cmd.as_std().get_args().map(|a| a.to_string_lossy().to_string()).collect();
789        assert!(args.contains(&"ServerAliveInterval=3".to_string()));
790        assert!(args.contains(&"StreamLocalBindUnlink=yes".to_string()));
791        assert!(args.contains(&"ExitOnForwardFailure=yes".to_string()));
792        assert!(args.contains(&"ConnectTimeout=5".to_string()));
793        assert!(args.contains(&"-N".to_string()));
794        assert!(args.contains(&"-T".to_string()));
795        assert!(args.contains(&"/tmp/local.sock:/run/user/1000/gritty/ctl.sock".to_string()));
796        assert!(args.contains(&"user@host".to_string()));
797    }
798
799    #[test]
800    fn tunnel_command_extra_opts() {
801        let dest = Destination::parse("host:2222").unwrap();
802        let cmd = tunnel_command(
803            &dest,
804            Path::new("/tmp/local.sock"),
805            "/tmp/remote.sock",
806            &["ProxyJump=bastion".to_string()],
807        );
808        let args: Vec<_> =
809            cmd.as_std().get_args().map(|a| a.to_string_lossy().to_string()).collect();
810        assert!(args.contains(&"ProxyJump=bastion".to_string()));
811        assert!(args.contains(&"-p".to_string()));
812        assert!(args.contains(&"2222".to_string()));
813    }
814
815    #[test]
816    fn local_socket_path_format() {
817        // With hostname-based naming, connect uses just the host part
818        let path = local_socket_path("devbox");
819        assert_eq!(path.file_name().unwrap().to_string_lossy(), "connect-devbox.sock");
820
821        let path = local_socket_path("example.com");
822        assert_eq!(path.file_name().unwrap().to_string_lossy(), "connect-example.com.sock");
823
824        // Custom name override
825        let path = local_socket_path("myproject");
826        assert_eq!(path.file_name().unwrap().to_string_lossy(), "connect-myproject.sock");
827    }
828
829    #[test]
830    fn connect_pid_path_format() {
831        let path = connect_pid_path("devbox");
832        assert_eq!(path.file_name().unwrap().to_string_lossy(), "connect-devbox.pid");
833
834        let path = connect_pid_path("example.com");
835        assert_eq!(path.file_name().unwrap().to_string_lossy(), "connect-example.com.pid");
836    }
837
838    #[test]
839    fn ssh_dest_with_user() {
840        let d = Destination::parse("alice@example.com").unwrap();
841        assert_eq!(d.ssh_dest(), "alice@example.com");
842    }
843
844    #[test]
845    fn ssh_dest_without_user() {
846        let d = Destination::parse("example.com").unwrap();
847        assert_eq!(d.ssh_dest(), "example.com");
848    }
849
850    #[test]
851    fn port_args_with_port() {
852        let d = Destination::parse("host:9999").unwrap();
853        assert_eq!(d.port_args(), vec!["-p", "9999"]);
854    }
855
856    #[test]
857    fn port_args_without_port() {
858        let d = Destination::parse("host").unwrap();
859        assert!(d.port_args().is_empty());
860    }
861
862    #[test]
863    fn shell_quote_simple() {
864        assert_eq!(shell_quote("hello"), "hello");
865        assert_eq!(shell_quote("-N"), "-N");
866        assert_eq!(shell_quote("ServerAliveInterval=3"), "ServerAliveInterval=3");
867        assert_eq!(shell_quote("user@host"), "user@host");
868        assert_eq!(
869            shell_quote("/tmp/local.sock:/tmp/remote.sock"),
870            "/tmp/local.sock:/tmp/remote.sock"
871        );
872        assert_eq!(shell_quote("$REMOTE_SOCK"), "$REMOTE_SOCK");
873    }
874
875    #[test]
876    fn shell_quote_needs_quoting() {
877        assert_eq!(shell_quote("hello world"), "'hello world'");
878        assert_eq!(shell_quote(""), "''");
879        assert_eq!(shell_quote("it's"), "'it'\\''s'");
880    }
881
882    #[test]
883    fn shell_quote_remote_cmd() {
884        // The wrapped remote command contains spaces, quotes, semicolons —
885        // must be single-quoted so $HOME expands on the remote side.
886        let cmd = format!("PATH=\"{REMOTE_PATH_PREFIX}\"; gritty socket-path");
887        let quoted = shell_quote(&cmd);
888        assert!(quoted.starts_with('\''));
889        assert!(quoted.ends_with('\''));
890    }
891
892    #[test]
893    fn format_command_tunnel() {
894        let dest = Destination::parse("user@host").unwrap();
895        let cmd = tunnel_command(&dest, Path::new("/tmp/local.sock"), "$REMOTE_SOCK", &[]);
896        let formatted = format_command(&cmd);
897        // Uses the same SSH_TUNNEL_OPTS
898        assert!(formatted.contains("ServerAliveInterval=3"));
899        assert!(formatted.contains("-N"));
900        assert!(formatted.contains("-T"));
901        // Forward arg references $REMOTE_SOCK unquoted (no spaces, $ is safe)
902        assert!(formatted.contains("/tmp/local.sock:$REMOTE_SOCK"));
903        assert!(formatted.contains("user@host"));
904    }
905
906    #[test]
907    fn format_command_remote_exec() {
908        let dest = Destination::parse("user@host:2222").unwrap();
909        let cmd = remote_exec_command(&dest, "gritty socket-path", &[]);
910        let formatted = format_command(&cmd);
911        assert!(formatted.starts_with("ssh "));
912        assert!(formatted.contains("-p 2222"));
913        assert!(formatted.contains("ConnectTimeout=5"));
914        assert!(formatted.contains("user@host"));
915        // The wrapped command should be single-quoted (contains spaces)
916        assert!(formatted.contains(&format!("PATH=\"{REMOTE_PATH_PREFIX}\"")));
917    }
918
919    #[test]
920    fn format_command_remote_exec_with_extra_opts() {
921        let dest = Destination::parse("user@host").unwrap();
922        let cmd = remote_exec_command(&dest, REMOTE_ENSURE_CMD, &["ProxyJump=bastion".to_string()]);
923        let formatted = format_command(&cmd);
924        assert!(formatted.contains("ProxyJump=bastion"));
925        assert!(formatted.contains("gritty socket-path"));
926        assert!(formatted.contains("gritty server"));
927    }
928
929    // -----------------------------------------------------------------------
930    // Lockfile and tunnel lifecycle tests
931    // -----------------------------------------------------------------------
932
933    #[test]
934    fn connect_lock_path_format() {
935        let path = connect_lock_path("devbox");
936        assert_eq!(path.file_name().unwrap().to_string_lossy(), "connect-devbox.lock");
937    }
938
939    #[test]
940    fn connect_dest_path_format() {
941        let path = connect_dest_path("devbox");
942        assert_eq!(path.file_name().unwrap().to_string_lossy(), "connect-devbox.dest");
943    }
944
945    #[test]
946    fn acquire_and_probe_lock() {
947        let dir = tempfile::tempdir().unwrap();
948        let lock_path = dir.path().join("test.lock");
949
950        // Lock not held initially (file doesn't exist)
951        assert!(!is_lock_held(&lock_path));
952
953        // Acquire the lock
954        let _fd = acquire_lock(&lock_path).unwrap();
955
956        // Now it should be held
957        assert!(is_lock_held(&lock_path));
958
959        // Drop the lock
960        drop(_fd);
961
962        // Should be free again
963        assert!(!is_lock_held(&lock_path));
964    }
965
966    #[test]
967    fn probe_stale_no_files() {
968        // No files at all → stale
969        let status = probe_tunnel_status("nonexistent-test-tunnel-xyz");
970        assert_eq!(status, TunnelStatus::Stale);
971    }
972
973    #[test]
974    fn cleanup_stale_files_removes_all() {
975        let _dir = tempfile::tempdir().unwrap();
976        // We can't easily override socket_dir(), so test that cleanup_stale_files
977        // at least doesn't panic on nonexistent files
978        cleanup_stale_files("nonexistent-cleanup-test-xyz");
979        // No panic = success
980    }
981
982    #[test]
983    fn enumerate_tunnels_empty_dir() {
984        // If socket dir doesn't have any lock files, should return empty
985        // This tests the function doesn't crash on various filesystem states
986        let names = enumerate_tunnels();
987        // We can't control what's in socket_dir during tests, but at minimum
988        // the function should not panic
989        let _ = names;
990    }
991
992    #[test]
993    fn connection_socket_path_matches_local() {
994        let public_path = connection_socket_path("myhost");
995        let internal_path = local_socket_path("myhost");
996        assert_eq!(public_path, internal_path);
997    }
998}