1use std::process::{Child, Command, Stdio};
2use std::time::{Duration, Instant};
3
4use anyhow::Result;
5use log::debug;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum TunnelType {
10 Local,
11 Remote,
12 Dynamic,
13}
14
15impl TunnelType {
16 pub fn label(self) -> &'static str {
17 match self {
18 TunnelType::Local => "Local",
19 TunnelType::Remote => "Remote",
20 TunnelType::Dynamic => "Dynamic",
21 }
22 }
23
24 pub fn directive_key(self) -> &'static str {
25 match self {
26 TunnelType::Local => "LocalForward",
27 TunnelType::Remote => "RemoteForward",
28 TunnelType::Dynamic => "DynamicForward",
29 }
30 }
31
32 pub fn next(self) -> Self {
33 match self {
34 TunnelType::Local => TunnelType::Remote,
35 TunnelType::Remote => TunnelType::Dynamic,
36 TunnelType::Dynamic => TunnelType::Local,
37 }
38 }
39
40 pub fn from_directive_key(key: &str) -> Option<Self> {
41 if key.eq_ignore_ascii_case("localforward") {
42 Some(TunnelType::Local)
43 } else if key.eq_ignore_ascii_case("remoteforward") {
44 Some(TunnelType::Remote)
45 } else if key.eq_ignore_ascii_case("dynamicforward") {
46 Some(TunnelType::Dynamic)
47 } else {
48 None
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq)]
55pub struct TunnelRule {
56 pub tunnel_type: TunnelType,
57 pub bind_address: String,
58 pub bind_port: u16,
59 pub remote_host: String,
60 pub remote_port: u16,
61}
62
63impl TunnelRule {
64 pub fn parse_value(key: &str, value: &str) -> Option<Self> {
70 let tunnel_type = TunnelType::from_directive_key(key)?;
71 let value = value.trim();
72
73 match tunnel_type {
74 TunnelType::Local | TunnelType::Remote => Self::parse_forward_value(tunnel_type, value),
75 TunnelType::Dynamic => Self::parse_dynamic_value(value),
76 }
77 }
78
79 fn parse_forward_value(tunnel_type: TunnelType, value: &str) -> Option<Self> {
80 let (bind_part, remote_part) = value.split_once(char::is_whitespace)?;
82 let remote_part = remote_part.trim();
83
84 let (bind_address, bind_port) = Self::parse_bind(bind_part)?;
85 let (remote_host, remote_port) = Self::parse_host_port(remote_part)?;
86
87 Some(TunnelRule {
88 tunnel_type,
89 bind_address,
90 bind_port,
91 remote_host,
92 remote_port,
93 })
94 }
95
96 fn parse_dynamic_value(value: &str) -> Option<Self> {
97 let (bind_address, bind_port) = Self::parse_bind(value)?;
98
99 Some(TunnelRule {
100 tunnel_type: TunnelType::Dynamic,
101 bind_address,
102 bind_port,
103 remote_host: String::new(),
104 remote_port: 0,
105 })
106 }
107
108 fn parse_bind(s: &str) -> Option<(String, u16)> {
110 if let Some(rest) = s.strip_prefix('[') {
112 let bracket_end = rest.find(']')?;
113 let addr = &rest[..bracket_end];
114 let after = &rest[bracket_end + 1..];
115 let port_str = after.strip_prefix(':')?;
116 let port: u16 = port_str.parse().ok()?;
117 return Some((addr.to_string(), port));
118 }
119 if let Ok(port) = s.parse::<u16>() {
121 return Some((String::new(), port));
122 }
123 let colon = s.rfind(':')?;
125 let addr = &s[..colon];
126 let port: u16 = s[colon + 1..].parse().ok()?;
127 Some((addr.to_string(), port))
128 }
129
130 fn parse_host_port(s: &str) -> Option<(String, u16)> {
132 if let Some(rest) = s.strip_prefix('[') {
134 let bracket_end = rest.find(']')?;
135 let host = &rest[..bracket_end];
136 let after = &rest[bracket_end + 1..];
137 let port_str = after.strip_prefix(':')?;
138 let port: u16 = port_str.parse().ok()?;
139 return Some((host.to_string(), port));
140 }
141 let colon = s.rfind(':')?;
143 let host = &s[..colon];
144 let port: u16 = s[colon + 1..].parse().ok()?;
145 Some((host.to_string(), port))
146 }
147
148 fn format_addr_port(addr: &str, port: u16) -> String {
150 if addr.contains(':') {
151 format!("[{}]:{}", addr, port)
152 } else {
153 format!("{}:{}", addr, port)
154 }
155 }
156
157 pub fn to_directive_value(&self) -> String {
159 match self.tunnel_type {
160 TunnelType::Local | TunnelType::Remote => {
161 let bind = if self.bind_address.is_empty() {
162 self.bind_port.to_string()
163 } else {
164 Self::format_addr_port(&self.bind_address, self.bind_port)
165 };
166 let remote = Self::format_addr_port(&self.remote_host, self.remote_port);
167 format!("{} {}", bind, remote)
168 }
169 TunnelType::Dynamic => {
170 if self.bind_address.is_empty() {
171 self.bind_port.to_string()
172 } else {
173 Self::format_addr_port(&self.bind_address, self.bind_port)
174 }
175 }
176 }
177 }
178
179 pub fn display(&self) -> String {
181 let bind = if self.bind_address.is_empty() {
182 self.bind_port.to_string()
183 } else {
184 Self::format_addr_port(&self.bind_address, self.bind_port)
185 };
186 match self.tunnel_type {
187 TunnelType::Local | TunnelType::Remote => {
188 let remote = Self::format_addr_port(&self.remote_host, self.remote_port);
189 format!("{:<8} {:<6} {}", self.tunnel_type.label(), bind, remote)
190 }
191 TunnelType::Dynamic => {
192 format!("{:<8} {:<6} (SOCKS proxy)", self.tunnel_type.label(), bind)
193 }
194 }
195 }
196
197 pub fn from_cli_spec(spec: &str) -> Result<Self, String> {
200 let (type_char, rest) = spec
201 .split_once(':')
202 .ok_or("Invalid format. Use L:port:host:port or D:port.")?;
203 let tunnel_type = match type_char {
204 "L" | "l" => TunnelType::Local,
205 "R" | "r" => TunnelType::Remote,
206 "D" | "d" => TunnelType::Dynamic,
207 _ => {
208 return Err(format!(
209 "Unknown tunnel type '{}'. Use L (local), R (remote) or D (dynamic).",
210 type_char
211 ));
212 }
213 };
214
215 match tunnel_type {
216 TunnelType::Dynamic => {
217 let port: u16 = rest
218 .parse()
219 .map_err(|_| "Invalid port for dynamic forward.")?;
220 if port == 0 {
221 return Err("Bind port can't be 0.".to_string());
222 }
223 Ok(TunnelRule {
224 tunnel_type,
225 bind_address: String::new(),
226 bind_port: port,
227 remote_host: String::new(),
228 remote_port: 0,
229 })
230 }
231 TunnelType::Local | TunnelType::Remote => {
232 let (bind_str, host_port) = rest
234 .split_once(':')
235 .ok_or("Invalid format. Use L:bind_port:host:port.")?;
236 let bind_port: u16 = bind_str.parse().map_err(|_| "Invalid bind port.")?;
237 if bind_port == 0 {
238 return Err("Bind port can't be 0.".to_string());
239 }
240 let (remote_host, remote_port) = Self::parse_host_port(host_port)
241 .ok_or("Invalid remote host:port. Use host:port or [IPv6]:port.")?;
242 if remote_host.is_empty() {
243 return Err("Remote host can't be empty.".to_string());
244 }
245 if remote_host.contains(char::is_whitespace) {
246 return Err("Remote host can't contain spaces.".to_string());
247 }
248 if remote_port == 0 {
249 return Err("Remote port can't be 0.".to_string());
250 }
251 Ok(TunnelRule {
252 tunnel_type,
253 bind_address: String::new(),
254 bind_port,
255 remote_host: remote_host.to_string(),
256 remote_port,
257 })
258 }
259 }
260 }
261}
262
263pub struct ActiveTunnel {
265 pub child: Child,
266 pub started_at: Instant,
270 pub live: crate::tunnel_live::TunnelLiveState,
272}
273
274impl Drop for ActiveTunnel {
275 fn drop(&mut self) {
276 self.live
280 .parser_stop
281 .store(true, std::sync::atomic::Ordering::Relaxed);
282 if let Some(handle) = self.live.parser_thread.take() {
283 let _ = handle.join();
284 }
285 }
286}
287
288impl ActiveTunnel {
289 pub fn spawn(
295 mut child: Child,
296 alias: &str,
297 parser_tx: std::sync::mpsc::Sender<crate::tunnel_live::ParserMessage>,
298 ) -> Self {
299 let started_at = Instant::now();
300 let mut live = crate::tunnel_live::TunnelLiveState::new(started_at);
301 if let Some(stderr) = child.stderr.take() {
302 let handle = crate::tunnel_live::spawn_parser_thread(
303 stderr,
304 alias.to_string(),
305 parser_tx,
306 live.stderr_buffer.clone(),
307 live.parser_stop.clone(),
308 );
309 live.parser_thread = Some(handle);
310 }
311 Self {
312 child,
313 started_at,
314 live,
315 }
316 }
317}
318
319pub fn format_uptime(elapsed: Duration) -> String {
327 let total = elapsed.as_secs();
328 if total < 60 {
329 format!("{}s", total)
330 } else if total < 3_600 {
331 let m = total / 60;
332 let s = total % 60;
333 format!("{}m {}s", m, s)
334 } else if total < 86_400 {
335 let h = total / 3_600;
336 let m = (total % 3_600) / 60;
337 format!("{}h {}m", h, m)
338 } else {
339 let d = total / 86_400;
340 let h = (total % 86_400) / 3_600;
341 format!("{}d {}h", d, h)
342 }
343}
344
345pub fn start_tunnel(
352 alias: &str,
353 config_path: &std::path::Path,
354 askpass: Option<&str>,
355 bw_session: Option<&str>,
356) -> Result<Child> {
357 let mut cmd = Command::new("ssh");
358 cmd.arg("-F")
359 .arg(config_path)
360 .arg("-v")
364 .arg("-N")
365 .arg("--")
366 .arg(alias)
367 .stdin(Stdio::null())
368 .stdout(Stdio::null())
369 .stderr(Stdio::piped());
370
371 if askpass.is_some() {
372 crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
373 }
374
375 if let Some(token) = bw_session {
376 cmd.env("BW_SESSION", token);
377 }
378
379 #[cfg(unix)]
380 unsafe {
386 use std::os::unix::process::CommandExt;
387 cmd.pre_exec(|| {
388 libc::setpgid(0, 0);
389 Ok(())
390 });
391 }
392
393 debug!(
394 "Tunnel SSH command: ssh -v -N -F {} -- {alias}",
395 config_path.display()
396 );
397
398 cmd.spawn()
399 .map_err(|e| anyhow::anyhow!("Failed to start tunnel: {}", e))
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
409 fn format_uptime_seconds_only() {
410 assert_eq!(format_uptime(Duration::from_secs(0)), "0s");
411 assert_eq!(format_uptime(Duration::from_secs(1)), "1s");
412 assert_eq!(format_uptime(Duration::from_secs(47)), "47s");
413 assert_eq!(format_uptime(Duration::from_secs(59)), "59s");
414 }
415
416 #[test]
417 fn format_uptime_minutes_seconds() {
418 assert_eq!(format_uptime(Duration::from_secs(60)), "1m 0s");
419 assert_eq!(format_uptime(Duration::from_secs(60 + 47)), "1m 47s");
420 assert_eq!(format_uptime(Duration::from_secs(12 * 60 + 47)), "12m 47s");
421 assert_eq!(format_uptime(Duration::from_secs(59 * 60 + 59)), "59m 59s");
422 }
423
424 #[test]
425 fn format_uptime_hours_minutes() {
426 assert_eq!(format_uptime(Duration::from_secs(3_600)), "1h 0m");
427 assert_eq!(
428 format_uptime(Duration::from_secs(2 * 3_600 + 14 * 60)),
429 "2h 14m"
430 );
431 assert_eq!(
433 format_uptime(Duration::from_secs(2 * 3_600 + 14 * 60 + 30)),
434 "2h 14m"
435 );
436 assert_eq!(
437 format_uptime(Duration::from_secs(23 * 3_600 + 59 * 60)),
438 "23h 59m"
439 );
440 }
441
442 #[test]
443 fn format_uptime_days_hours() {
444 assert_eq!(format_uptime(Duration::from_secs(86_400)), "1d 0h");
445 assert_eq!(
446 format_uptime(Duration::from_secs(3 * 86_400 + 4 * 3_600)),
447 "3d 4h"
448 );
449 assert_eq!(
451 format_uptime(Duration::from_secs(3 * 86_400 + 4 * 3_600 + 30 * 60)),
452 "3d 4h"
453 );
454 assert_eq!(
455 format_uptime(Duration::from_secs(365 * 86_400 + 12 * 3_600)),
456 "365d 12h"
457 );
458 }
459
460 #[test]
463 fn tunnel_type_from_directive_key() {
464 assert_eq!(
465 TunnelType::from_directive_key("LocalForward"),
466 Some(TunnelType::Local)
467 );
468 assert_eq!(
469 TunnelType::from_directive_key("localforward"),
470 Some(TunnelType::Local)
471 );
472 assert_eq!(
473 TunnelType::from_directive_key("RemoteForward"),
474 Some(TunnelType::Remote)
475 );
476 assert_eq!(
477 TunnelType::from_directive_key("DynamicForward"),
478 Some(TunnelType::Dynamic)
479 );
480 assert_eq!(TunnelType::from_directive_key("HostName"), None);
481 }
482
483 #[test]
484 fn tunnel_type_cycle() {
485 assert_eq!(TunnelType::Local.next(), TunnelType::Remote);
486 assert_eq!(TunnelType::Remote.next(), TunnelType::Dynamic);
487 assert_eq!(TunnelType::Dynamic.next(), TunnelType::Local);
488 }
490
491 #[test]
494 fn parse_local_forward_port_only() {
495 let rule = TunnelRule::parse_value("LocalForward", "8080 localhost:80").unwrap();
496 assert_eq!(rule.tunnel_type, TunnelType::Local);
497 assert_eq!(rule.bind_address, "");
498 assert_eq!(rule.bind_port, 8080);
499 assert_eq!(rule.remote_host, "localhost");
500 assert_eq!(rule.remote_port, 80);
501 }
502
503 #[test]
504 fn parse_local_forward_with_bind_address() {
505 let rule = TunnelRule::parse_value("LocalForward", "127.0.0.1:8080 localhost:80").unwrap();
506 assert_eq!(rule.bind_address, "127.0.0.1");
507 assert_eq!(rule.bind_port, 8080);
508 assert_eq!(rule.remote_host, "localhost");
509 assert_eq!(rule.remote_port, 80);
510 }
511
512 #[test]
513 fn parse_remote_forward() {
514 let rule = TunnelRule::parse_value("RemoteForward", "9090 localhost:3000").unwrap();
515 assert_eq!(rule.tunnel_type, TunnelType::Remote);
516 assert_eq!(rule.bind_port, 9090);
517 assert_eq!(rule.remote_host, "localhost");
518 assert_eq!(rule.remote_port, 3000);
519 }
520
521 #[test]
522 fn parse_dynamic_forward_port_only() {
523 let rule = TunnelRule::parse_value("DynamicForward", "1080").unwrap();
524 assert_eq!(rule.tunnel_type, TunnelType::Dynamic);
525 assert_eq!(rule.bind_address, "");
526 assert_eq!(rule.bind_port, 1080);
527 assert_eq!(rule.remote_host, "");
528 assert_eq!(rule.remote_port, 0);
529 }
530
531 #[test]
532 fn parse_dynamic_forward_with_bind_address() {
533 let rule = TunnelRule::parse_value("DynamicForward", "127.0.0.1:1080").unwrap();
534 assert_eq!(rule.bind_address, "127.0.0.1");
535 assert_eq!(rule.bind_port, 1080);
536 }
537
538 #[test]
539 fn parse_unknown_directive_returns_none() {
540 assert!(TunnelRule::parse_value("HostName", "example.com").is_none());
541 }
542
543 #[test]
544 fn parse_invalid_value_returns_none() {
545 assert!(TunnelRule::parse_value("LocalForward", "not_a_number").is_none());
546 assert!(TunnelRule::parse_value("LocalForward", "").is_none());
547 }
548
549 #[test]
550 fn parse_ipv6_bind_address() {
551 let rule = TunnelRule::parse_value("LocalForward", "[::1]:8080 localhost:80").unwrap();
552 assert_eq!(rule.bind_address, "::1");
553 assert_eq!(rule.bind_port, 8080);
554 }
555
556 #[test]
557 fn parse_high_port_numbers() {
558 let rule = TunnelRule::parse_value("LocalForward", "65535 localhost:65535").unwrap();
559 assert_eq!(rule.bind_port, 65535);
560 assert_eq!(rule.remote_port, 65535);
561 }
562
563 #[test]
566 fn to_directive_value_local() {
567 let rule = TunnelRule {
568 tunnel_type: TunnelType::Local,
569 bind_address: String::new(),
570 bind_port: 8080,
571 remote_host: "localhost".to_string(),
572 remote_port: 80,
573 };
574 assert_eq!(rule.to_directive_value(), "8080 localhost:80");
575 }
576
577 #[test]
578 fn to_directive_value_local_with_bind() {
579 let rule = TunnelRule {
580 tunnel_type: TunnelType::Local,
581 bind_address: "127.0.0.1".to_string(),
582 bind_port: 8080,
583 remote_host: "localhost".to_string(),
584 remote_port: 80,
585 };
586 assert_eq!(rule.to_directive_value(), "127.0.0.1:8080 localhost:80");
587 }
588
589 #[test]
590 fn to_directive_value_dynamic() {
591 let rule = TunnelRule {
592 tunnel_type: TunnelType::Dynamic,
593 bind_address: String::new(),
594 bind_port: 1080,
595 remote_host: String::new(),
596 remote_port: 0,
597 };
598 assert_eq!(rule.to_directive_value(), "1080");
599 }
600
601 #[test]
602 fn roundtrip_local_forward() {
603 let original = "8080 localhost:80";
604 let rule = TunnelRule::parse_value("LocalForward", original).unwrap();
605 assert_eq!(rule.to_directive_value(), original);
606 }
607
608 #[test]
609 fn roundtrip_local_forward_with_bind() {
610 let original = "127.0.0.1:8080 localhost:80";
611 let rule = TunnelRule::parse_value("LocalForward", original).unwrap();
612 assert_eq!(rule.to_directive_value(), original);
613 }
614
615 #[test]
616 fn roundtrip_dynamic_forward() {
617 let original = "1080";
618 let rule = TunnelRule::parse_value("DynamicForward", original).unwrap();
619 assert_eq!(rule.to_directive_value(), original);
620 }
621
622 #[test]
625 fn from_cli_spec_local() {
626 let rule = TunnelRule::from_cli_spec("L:8080:localhost:80").unwrap();
627 assert_eq!(rule.tunnel_type, TunnelType::Local);
628 assert_eq!(rule.bind_port, 8080);
629 assert_eq!(rule.remote_host, "localhost");
630 assert_eq!(rule.remote_port, 80);
631 }
632
633 #[test]
634 fn from_cli_spec_remote() {
635 let rule = TunnelRule::from_cli_spec("R:9090:localhost:3000").unwrap();
636 assert_eq!(rule.tunnel_type, TunnelType::Remote);
637 assert_eq!(rule.bind_port, 9090);
638 }
639
640 #[test]
641 fn from_cli_spec_dynamic() {
642 let rule = TunnelRule::from_cli_spec("D:1080").unwrap();
643 assert_eq!(rule.tunnel_type, TunnelType::Dynamic);
644 assert_eq!(rule.bind_port, 1080);
645 }
646
647 #[test]
648 fn from_cli_spec_lowercase() {
649 let rule = TunnelRule::from_cli_spec("l:8080:localhost:80").unwrap();
650 assert_eq!(rule.tunnel_type, TunnelType::Local);
651 }
652
653 #[test]
654 fn from_cli_spec_invalid() {
655 assert!(TunnelRule::from_cli_spec("X:8080").is_err());
656 assert!(TunnelRule::from_cli_spec("L:abc:localhost:80").is_err());
657 assert!(TunnelRule::from_cli_spec("garbage").is_err());
658 }
659
660 #[test]
663 fn display_local() {
664 let rule = TunnelRule {
665 tunnel_type: TunnelType::Local,
666 bind_address: String::new(),
667 bind_port: 8080,
668 remote_host: "localhost".to_string(),
669 remote_port: 80,
670 };
671 let d = rule.display();
672 assert!(d.contains("Local"));
673 assert!(d.contains("8080"));
674 assert!(d.contains("localhost:80"));
675 }
676
677 #[test]
678 fn display_dynamic() {
679 let rule = TunnelRule {
680 tunnel_type: TunnelType::Dynamic,
681 bind_address: String::new(),
682 bind_port: 1080,
683 remote_host: String::new(),
684 remote_port: 0,
685 };
686 let d = rule.display();
687 assert!(d.contains("Dynamic"));
688 assert!(d.contains("SOCKS proxy"));
689 }
690
691 #[test]
694 fn to_directive_value_ipv6_bind() {
695 let rule = TunnelRule {
696 tunnel_type: TunnelType::Local,
697 bind_address: "::1".to_string(),
698 bind_port: 8080,
699 remote_host: "localhost".to_string(),
700 remote_port: 80,
701 };
702 assert_eq!(rule.to_directive_value(), "[::1]:8080 localhost:80");
703 }
704
705 #[test]
706 fn to_directive_value_ipv6_remote() {
707 let rule = TunnelRule {
708 tunnel_type: TunnelType::Local,
709 bind_address: String::new(),
710 bind_port: 8080,
711 remote_host: "fe80::1".to_string(),
712 remote_port: 80,
713 };
714 assert_eq!(rule.to_directive_value(), "8080 [fe80::1]:80");
715 }
716
717 #[test]
718 fn to_directive_value_ipv6_both() {
719 let rule = TunnelRule {
720 tunnel_type: TunnelType::Local,
721 bind_address: "::1".to_string(),
722 bind_port: 8080,
723 remote_host: "::1".to_string(),
724 remote_port: 80,
725 };
726 assert_eq!(rule.to_directive_value(), "[::1]:8080 [::1]:80");
727 }
728
729 #[test]
730 fn roundtrip_ipv6_bind() {
731 let original = "[::1]:8080 localhost:80";
732 let rule = TunnelRule::parse_value("LocalForward", original).unwrap();
733 assert_eq!(rule.bind_address, "::1");
734 assert_eq!(rule.to_directive_value(), original);
735 }
736
737 #[test]
738 fn roundtrip_ipv6_remote() {
739 let original = "8080 [fe80::1]:80";
740 let rule = TunnelRule::parse_value("LocalForward", original).unwrap();
741 assert_eq!(rule.remote_host, "fe80::1");
742 assert_eq!(rule.to_directive_value(), original);
743 }
744
745 #[test]
746 fn roundtrip_ipv6_both() {
747 let original = "[::1]:8080 [::1]:80";
748 let rule = TunnelRule::parse_value("LocalForward", original).unwrap();
749 assert_eq!(rule.to_directive_value(), original);
750 }
751
752 #[test]
753 fn roundtrip_ipv6_dynamic() {
754 let original = "[::1]:1080";
755 let rule = TunnelRule::parse_value("DynamicForward", original).unwrap();
756 assert_eq!(rule.bind_address, "::1");
757 assert_eq!(rule.to_directive_value(), original);
758 }
759
760 #[test]
761 fn to_directive_value_ipv6_dynamic() {
762 let rule = TunnelRule {
763 tunnel_type: TunnelType::Dynamic,
764 bind_address: "::1".to_string(),
765 bind_port: 1080,
766 remote_host: String::new(),
767 remote_port: 0,
768 };
769 assert_eq!(rule.to_directive_value(), "[::1]:1080");
770 }
771
772 #[test]
773 fn display_ipv6_brackets() {
774 let rule = TunnelRule {
775 tunnel_type: TunnelType::Local,
776 bind_address: "::1".to_string(),
777 bind_port: 8080,
778 remote_host: "::1".to_string(),
779 remote_port: 80,
780 };
781 let d = rule.display();
782 assert!(d.contains("[::1]:8080"));
783 assert!(d.contains("[::1]:80"));
784 }
785
786 #[test]
789 fn parse_port_1_minimum() {
790 let rule = TunnelRule::parse_value("LocalForward", "1 localhost:1").unwrap();
791 assert_eq!(rule.bind_port, 1);
792 assert_eq!(rule.remote_port, 1);
793 }
794
795 #[test]
796 fn parse_port_0_accepted() {
797 let rule = TunnelRule::parse_value("DynamicForward", "0");
799 assert!(rule.is_some());
800 }
801
802 #[test]
803 fn parse_port_65536_rejected() {
804 assert!(TunnelRule::parse_value("DynamicForward", "65536").is_none());
806 }
807
808 #[test]
809 fn parse_port_negative_rejected() {
810 assert!(TunnelRule::parse_value("DynamicForward", "-1").is_none());
811 }
812
813 #[test]
816 fn parse_multiple_spaces_between_parts() {
817 let rule = TunnelRule::parse_value("LocalForward", "8080 localhost:80").unwrap();
818 assert_eq!(rule.bind_port, 8080);
819 assert_eq!(rule.remote_host, "localhost");
820 assert_eq!(rule.remote_port, 80);
821 }
822
823 #[test]
824 fn parse_tab_between_parts() {
825 let rule = TunnelRule::parse_value("LocalForward", "8080\tlocalhost:80").unwrap();
826 assert_eq!(rule.bind_port, 8080);
827 assert_eq!(rule.remote_host, "localhost");
828 }
829
830 #[test]
831 fn parse_leading_trailing_whitespace() {
832 let rule = TunnelRule::parse_value("LocalForward", " 8080 localhost:80 ").unwrap();
833 assert_eq!(rule.bind_port, 8080);
834 }
835
836 #[test]
839 fn parse_empty_string() {
840 assert!(TunnelRule::parse_value("LocalForward", "").is_none());
841 }
842
843 #[test]
844 fn parse_single_word() {
845 assert!(TunnelRule::parse_value("LocalForward", "garbage").is_none());
846 }
847
848 #[test]
849 fn parse_missing_remote_port() {
850 assert!(TunnelRule::parse_value("LocalForward", "8080 localhost").is_none());
851 }
852
853 #[test]
854 fn parse_missing_remote_host() {
855 let rule = TunnelRule::parse_value("LocalForward", "8080 :80").unwrap();
858 assert_eq!(rule.remote_host, "");
859 assert_eq!(rule.remote_port, 80);
860 }
861
862 #[test]
863 fn parse_empty_brackets() {
864 let rule = TunnelRule::parse_value("LocalForward", "[]:8080 localhost:80").unwrap();
866 assert_eq!(rule.bind_address, "");
867 }
868
869 #[test]
870 fn parse_mismatched_bracket() {
871 assert!(TunnelRule::parse_value("LocalForward", "[::1:8080 localhost:80").is_none());
872 }
873
874 #[test]
877 fn from_cli_spec_empty_bind_port() {
878 assert!(TunnelRule::from_cli_spec("L::localhost:80").is_err());
879 }
880
881 #[test]
882 fn from_cli_spec_extra_colons() {
883 assert!(TunnelRule::from_cli_spec("R:8080:host:port:extra").is_err());
885 }
886
887 #[test]
888 fn from_cli_spec_dynamic_non_numeric() {
889 assert!(TunnelRule::from_cli_spec("D:abc").is_err());
890 }
891
892 #[test]
893 fn from_cli_spec_no_colons() {
894 assert!(TunnelRule::from_cli_spec("L8080").is_err());
895 }
896
897 #[test]
898 fn from_cli_spec_missing_parts() {
899 assert!(TunnelRule::from_cli_spec("L:8080").is_err());
900 assert!(TunnelRule::from_cli_spec("L:8080:localhost").is_err());
901 }
902
903 #[test]
904 fn from_cli_spec_empty_remote_host() {
905 assert!(TunnelRule::from_cli_spec("L:8080::80").is_err());
906 assert!(TunnelRule::from_cli_spec("R:9090::3000").is_err());
907 }
908
909 #[test]
912 fn roundtrip_remote_forward() {
913 let original = "9090 localhost:3000";
914 let rule = TunnelRule::parse_value("RemoteForward", original).unwrap();
915 assert_eq!(rule.to_directive_value(), original);
916 }
917
918 #[test]
919 fn roundtrip_remote_forward_with_bind() {
920 let original = "0.0.0.0:9090 localhost:3000";
921 let rule = TunnelRule::parse_value("RemoteForward", original).unwrap();
922 assert_eq!(rule.to_directive_value(), original);
923 }
924
925 #[test]
926 fn roundtrip_dynamic_with_bind() {
927 let original = "127.0.0.1:1080";
928 let rule = TunnelRule::parse_value("DynamicForward", original).unwrap();
929 assert_eq!(rule.to_directive_value(), original);
930 }
931
932 #[test]
935 fn from_cli_spec_local_ipv6_remote() {
936 let rule = TunnelRule::from_cli_spec("L:8080:[::1]:80").unwrap();
937 assert_eq!(rule.tunnel_type, TunnelType::Local);
938 assert_eq!(rule.bind_port, 8080);
939 assert_eq!(rule.remote_host, "::1");
940 assert_eq!(rule.remote_port, 80);
941 }
942
943 #[test]
944 fn from_cli_spec_remote_ipv6_remote() {
945 let rule = TunnelRule::from_cli_spec("R:9090:[fe80::1]:3000").unwrap();
946 assert_eq!(rule.tunnel_type, TunnelType::Remote);
947 assert_eq!(rule.bind_port, 9090);
948 assert_eq!(rule.remote_host, "fe80::1");
949 assert_eq!(rule.remote_port, 3000);
950 }
951
952 #[test]
955 fn from_cli_spec_bind_port_0_rejected() {
956 assert!(TunnelRule::from_cli_spec("L:0:localhost:80").is_err());
957 assert!(TunnelRule::from_cli_spec("R:0:localhost:80").is_err());
958 assert!(TunnelRule::from_cli_spec("D:0").is_err());
959 }
960
961 #[test]
962 fn from_cli_spec_remote_port_0_rejected() {
963 assert!(TunnelRule::from_cli_spec("L:8080:localhost:0").is_err());
964 assert!(TunnelRule::from_cli_spec("R:9090:localhost:0").is_err());
965 }
966
967 #[test]
970 fn from_cli_spec_dynamic_empty_port() {
971 assert!(TunnelRule::from_cli_spec("D:").is_err());
972 }
973
974 #[test]
975 fn from_cli_spec_dynamic_trailing_content() {
976 assert!(TunnelRule::from_cli_spec("D:1080:extra").is_err());
977 }
978
979 #[test]
980 fn from_cli_spec_port_overflow() {
981 assert!(TunnelRule::from_cli_spec("L:65536:localhost:80").is_err());
982 assert!(TunnelRule::from_cli_spec("D:65536").is_err());
983 }
984
985 #[test]
986 fn from_cli_spec_multi_char_type() {
987 assert!(TunnelRule::from_cli_spec("LOCAL:8080:localhost:80").is_err());
988 }
989
990 #[test]
991 fn from_cli_spec_bare_ipv6_remote() {
992 let rule = TunnelRule::from_cli_spec("L:8080:::1:80").unwrap();
994 assert_eq!(rule.remote_host, "::1");
995 assert_eq!(rule.remote_port, 80);
996 }
997
998 #[test]
1001 fn from_cli_spec_error_unknown_type_message() {
1002 let err = TunnelRule::from_cli_spec("X:8080:localhost:80").unwrap_err();
1003 assert!(err.contains("Unknown tunnel type"), "got: {}", err);
1004 }
1005
1006 #[test]
1007 fn from_cli_spec_error_no_colon_message() {
1008 let err = TunnelRule::from_cli_spec("L8080").unwrap_err();
1009 assert!(err.contains("Invalid format"), "got: {}", err);
1010 }
1011
1012 #[test]
1013 fn from_cli_spec_error_bind_port_0_message() {
1014 let err = TunnelRule::from_cli_spec("L:0:localhost:80").unwrap_err();
1015 assert!(err.contains("0"), "got: {}", err);
1016 }
1017
1018 #[test]
1019 fn from_cli_spec_error_remote_port_0_message() {
1020 let err = TunnelRule::from_cli_spec("L:8080:localhost:0").unwrap_err();
1021 assert!(err.contains("0"), "got: {}", err);
1022 }
1023
1024 #[test]
1025 fn from_cli_spec_error_whitespace_in_remote_host() {
1026 let err = TunnelRule::from_cli_spec("L:8080:local host:80").unwrap_err();
1027 assert!(err.contains("spaces"), "got: {}", err);
1028 }
1029
1030 #[test]
1031 fn from_cli_spec_error_empty_remote_host_message() {
1032 let err = TunnelRule::from_cli_spec("L:8080::80").unwrap_err();
1033 assert!(err.contains("empty"), "got: {}", err);
1034 }
1035
1036 #[test]
1037 fn from_cli_spec_error_dynamic_invalid_port_message() {
1038 let err = TunnelRule::from_cli_spec("D:abc").unwrap_err();
1039 assert!(err.contains("port"), "got: {}", err);
1040 }
1041
1042 #[test]
1049 fn start_tunnel_askpass_none_does_not_set_env() {
1050 let askpass: Option<&str> = None;
1053 assert!(askpass.is_none());
1054 }
1055
1056 #[test]
1057 fn start_tunnel_askpass_some_triggers_env_setup() {
1058 let askpass: Option<&str> = Some("keychain");
1059 assert!(askpass.is_some());
1060 }
1061
1062 #[test]
1063 fn start_tunnel_askpass_empty_string_still_triggers() {
1064 let askpass: Option<&str> = Some("");
1066 assert!(askpass.is_some());
1067 }
1068
1069 #[test]
1070 fn start_tunnel_askpass_all_source_types_trigger() {
1071 let sources = [
1072 "keychain",
1073 "op://Vault/Item/pw",
1074 "bw:my-item",
1075 "pass:ssh/server",
1076 "vault:secret/ssh#pw",
1077 "my-script %h",
1078 ];
1079 for source in &sources {
1080 let askpass: Option<&str> = Some(source);
1081 assert!(
1082 askpass.is_some(),
1083 "askpass '{}' should trigger env setup",
1084 source
1085 );
1086 }
1087 }
1088
1089 #[test]
1090 fn start_tunnel_env_var_names_match_connection() {
1091 let expected = [
1093 "SSH_ASKPASS",
1094 "SSH_ASKPASS_REQUIRE",
1095 "PURPLE_ASKPASS_MODE",
1096 "PURPLE_HOST_ALIAS",
1097 ];
1098 assert_eq!(expected.len(), 4);
1099 assert_eq!(expected[2], "PURPLE_ASKPASS_MODE");
1100 }
1101
1102 #[test]
1111 fn start_tunnel_sets_config_path_env() {
1112 let env_vars = [
1114 "SSH_ASKPASS",
1115 "SSH_ASKPASS_REQUIRE",
1116 "PURPLE_ASKPASS_MODE",
1117 "PURPLE_HOST_ALIAS",
1118 "PURPLE_CONFIG_PATH",
1119 ];
1120 assert!(env_vars.contains(&"PURPLE_CONFIG_PATH"));
1121 }
1122
1123 #[test]
1124 fn start_tunnel_does_not_set_bw_session() {
1125 let tunnel_env_vars = [
1129 "SSH_ASKPASS",
1130 "SSH_ASKPASS_REQUIRE",
1131 "PURPLE_ASKPASS_MODE",
1132 "PURPLE_HOST_ALIAS",
1133 "PURPLE_CONFIG_PATH",
1134 ];
1135 assert!(!tunnel_env_vars.contains(&"BW_SESSION"));
1136 }
1137
1138 #[test]
1139 fn start_tunnel_stdin_is_null() {
1140 let stdin_mode = "null";
1143 assert_eq!(stdin_mode, "null");
1144 }
1145
1146 #[test]
1147 fn start_tunnel_uses_dash_n_flag() {
1148 let flag = "-N";
1150 assert_eq!(flag, "-N");
1151 }
1152}