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#[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 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 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
72const 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
92const REMOTE_PATH_PREFIX: &str = "$HOME/bin:$HOME/.local/bin:$HOME/.cargo/bin:$PATH";
95
96fn 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
110async 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
140fn 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
152fn 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
160fn 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
182async 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
196async 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
210async 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 if let Some(c) = code
247 && c != 255
248 {
249 warn!("ssh tunnel exited with code {c} (not retrying)");
250 return;
251 }
252
253 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
283const 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
293async 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
311fn 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
336pub fn connection_socket_path(connection_name: &str) -> PathBuf {
339 local_socket_path(connection_name)
340}
341
342fn 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
364fn 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 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
374 false
376 } else {
377 true }
379}
380
381#[derive(Debug, PartialEq, Eq)]
383pub enum TunnelStatus {
384 Healthy,
385 Reconnecting,
386 Stale,
387}
388
389fn 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
404fn 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 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
422fn 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
441struct 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 }
473}
474
475pub 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 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 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_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_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 let lock_fd = acquire_lock(&lock_path)?;
560
561 let remote_sock = ensure_remote_ready(&dest, opts.no_server_start, &opts.ssh_options).await?;
563 debug!(remote_sock, "remote socket path");
564
565 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 wait_for_socket(&local_sock).await?;
581 debug!("tunnel socket ready");
582
583 let _ = std::fs::write(&pid_file, std::process::id().to_string());
585 let _ = std::fs::write(&dest_file, &opts.destination);
586
587 signal_ready(&ready_fd);
589
590 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 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
603 tokio::select! {
604 _ = sigterm.recv() => {}
605 _ = monitor_handle => {}
606 }
607
608 drop(guard);
610
611 Ok(0)
612}
613
614fn 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 std::fs::File::from_raw_fd(fd.as_raw_fd())
621 });
622 let _ = f.write_all(b"\x01");
623 let _ = f.flush();
624 std::mem::forget(f);
626 }
627}
628
629pub 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 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 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 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
679pub fn list_tunnels() {
684 let names = enumerate_tunnels();
685 if names.is_empty() {
686 println!("no active tunnels");
687 return;
688 }
689
690 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#[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 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 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 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 assert!(formatted.contains("ServerAliveInterval=3"));
899 assert!(formatted.contains("-N"));
900 assert!(formatted.contains("-T"));
901 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 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 #[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 assert!(!is_lock_held(&lock_path));
952
953 let _fd = acquire_lock(&lock_path).unwrap();
955
956 assert!(is_lock_held(&lock_path));
958
959 drop(_fd);
961
962 assert!(!is_lock_held(&lock_path));
964 }
965
966 #[test]
967 fn probe_stale_no_files() {
968 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 cleanup_stale_files("nonexistent-cleanup-test-xyz");
979 }
981
982 #[test]
983 fn enumerate_tunnels_empty_dir() {
984 let names = enumerate_tunnels();
987 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}