1use std::collections::{HashMap, HashSet};
2use std::sync::atomic::Ordering;
3use std::sync::mpsc::{Receiver, Sender};
4use std::sync::{Arc, Mutex};
5use std::time::Instant;
6
7use crate::app::TunnelFormBaseline;
8use crate::app::forms::TunnelForm;
9use crate::tunnel::{ActiveTunnel, TunnelRule};
10
11const EXIT_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(1);
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TunnelExit {
19 pub alias: String,
20 pub message: String,
22 pub is_error: bool,
23 pub auth_refused: bool,
25 pub authenticated: bool,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum TunnelSortMode {
32 #[default]
35 MostRecent,
36 AlphaHostname,
38}
39
40impl TunnelSortMode {
41 pub fn next(self) -> Self {
42 match self {
43 TunnelSortMode::MostRecent => TunnelSortMode::AlphaHostname,
44 TunnelSortMode::AlphaHostname => TunnelSortMode::MostRecent,
45 }
46 }
47
48 pub fn label(self) -> &'static str {
49 match self {
50 TunnelSortMode::MostRecent => "most recent",
51 TunnelSortMode::AlphaHostname => "A-Z hostname",
52 }
53 }
54}
55use crate::tunnel_live::{
56 ChannelEventKind, ClientPeer, LsofMessage, LsofPollerHandle, PEER_VIZ_BUCKETS, ParserMessage,
57 PortConflict, TunnelLiveSnapshot,
58};
59
60pub struct TunnelState {
66 pub(in crate::app) list: Vec<TunnelRule>,
67 pub(in crate::app) form: TunnelForm,
68 pub(in crate::app) active: HashMap<String, ActiveTunnel>,
69 pub(in crate::app) form_baseline: Option<TunnelFormBaseline>,
70 pub(in crate::app) pending_delete: Option<usize>,
71 pub(in crate::app) summaries_cache: HashMap<String, String>,
72 pub(in crate::app) sort_mode: TunnelSortMode,
74
75 pub(in crate::app) parser_tx: Sender<ParserMessage>,
79 pub(in crate::app) parser_rx: Receiver<ParserMessage>,
80 pub(in crate::app) lsof_tx: Sender<LsofMessage>,
81 pub(in crate::app) lsof_rx: Receiver<LsofMessage>,
82
83 pub(in crate::app) clients: HashMap<u16, Vec<ClientPeer>>,
86 pub(in crate::app) conflicts: HashMap<u16, PortConflict>,
87 pub(in crate::app) last_lsof_at: Option<Instant>,
88
89 pub(in crate::app) peer_viz: HashMap<(u16, String), [u64; PEER_VIZ_BUCKETS]>,
97 pub(in crate::app) peer_viz_last_push: Option<Instant>,
104 pub(in crate::app) peer_viz_prev_push: Option<Instant>,
105
106 pub(in crate::app) lsof: Option<LsofPollerHandle>,
110
111 pub(in crate::app) demo_live_snapshots: HashMap<String, TunnelLiveSnapshot>,
115}
116
117impl Default for TunnelState {
118 fn default() -> Self {
119 let (parser_tx, parser_rx) = std::sync::mpsc::channel::<ParserMessage>();
120 let (lsof_tx, lsof_rx) = std::sync::mpsc::channel::<LsofMessage>();
121 Self {
122 list: Vec::new(),
123 form: TunnelForm::new(),
124 active: HashMap::new(),
125 form_baseline: None,
126 pending_delete: None,
127 summaries_cache: HashMap::new(),
128 sort_mode: TunnelSortMode::default(),
129 parser_tx,
130 parser_rx,
131 lsof_tx,
132 lsof_rx,
133 clients: HashMap::new(),
134 conflicts: HashMap::new(),
135 last_lsof_at: None,
136 peer_viz: HashMap::new(),
137 peer_viz_last_push: None,
138 peer_viz_prev_push: None,
139 lsof: None,
140 demo_live_snapshots: HashMap::new(),
141 }
142 }
143}
144
145impl Drop for TunnelState {
146 fn drop(&mut self) {
147 if let Some(mut handle) = self.lsof.take() {
148 handle.shutdown();
149 }
150 }
151}
152
153impl TunnelState {
154 pub fn list(&self) -> &[TunnelRule] {
155 &self.list
156 }
157
158 pub fn list_mut(&mut self) -> &mut Vec<TunnelRule> {
159 &mut self.list
160 }
161
162 pub fn form(&self) -> &TunnelForm {
163 &self.form
164 }
165
166 pub fn form_mut(&mut self) -> &mut TunnelForm {
167 &mut self.form
168 }
169
170 pub fn reset_form(&mut self) {
171 self.form = TunnelForm::new();
172 }
173
174 pub fn load_directives(
176 &mut self,
177 config: &crate::ssh_config::model::SshConfigFile,
178 alias: &str,
179 ) {
180 self.list = config.find_tunnel_directives(alias);
181 }
182
183 pub fn form_is_dirty(&self) -> bool {
185 match &self.form_baseline {
186 Some(b) => {
187 self.form.tunnel_type != b.tunnel_type
188 || self.form.bind_port != b.bind_port
189 || self.form.remote_host != b.remote_host
190 || self.form.remote_port != b.remote_port
191 || self.form.bind_address != b.bind_address
192 }
193 None => false,
194 }
195 }
196
197 pub fn active(&self) -> &HashMap<String, ActiveTunnel> {
198 &self.active
199 }
200
201 pub fn active_get(&self, alias: &str) -> Option<&ActiveTunnel> {
202 self.active.get(alias)
203 }
204
205 pub fn active_get_mut(&mut self, alias: &str) -> Option<&mut ActiveTunnel> {
206 self.active.get_mut(alias)
207 }
208
209 pub fn active_contains(&self, alias: &str) -> bool {
210 self.active.contains_key(alias)
211 }
212
213 pub fn active_insert(&mut self, alias: String, tunnel: ActiveTunnel) {
214 self.active.insert(alias, tunnel);
215 }
216
217 pub fn active_remove(&mut self, alias: &str) -> Option<ActiveTunnel> {
218 self.active.remove(alias)
219 }
220
221 pub fn drain_active(&mut self) -> std::collections::hash_map::Drain<'_, String, ActiveTunnel> {
222 self.active.drain()
223 }
224
225 pub fn clear_active(&mut self) {
226 self.active.clear();
227 }
228
229 pub fn pending_delete(&self) -> Option<usize> {
230 self.pending_delete
231 }
232
233 pub fn take_pending_delete(&mut self) -> Option<usize> {
234 self.pending_delete.take()
235 }
236
237 pub fn sort_mode(&self) -> TunnelSortMode {
238 self.sort_mode
239 }
240
241 pub fn set_sort_mode(&mut self, mode: TunnelSortMode) {
242 self.sort_mode = mode;
243 }
244
245 pub fn form_baseline(&self) -> Option<&TunnelFormBaseline> {
246 self.form_baseline.as_ref()
247 }
248
249 pub fn set_form_baseline(&mut self, baseline: Option<TunnelFormBaseline>) {
250 self.form_baseline = baseline;
251 }
252
253 pub fn demo_live_snapshots(&self) -> &HashMap<String, crate::tunnel_live::TunnelLiveSnapshot> {
254 &self.demo_live_snapshots
255 }
256
257 pub fn demo_live_snapshots_mut(
258 &mut self,
259 ) -> &mut HashMap<String, crate::tunnel_live::TunnelLiveSnapshot> {
260 &mut self.demo_live_snapshots
261 }
262
263 pub fn parser_tx(&self) -> Sender<ParserMessage> {
264 self.parser_tx.clone()
265 }
266
267 pub fn clients(&self) -> &HashMap<u16, Vec<ClientPeer>> {
268 &self.clients
269 }
270
271 pub fn peer_viz(&self) -> &HashMap<(u16, String), [u64; PEER_VIZ_BUCKETS]> {
272 &self.peer_viz
273 }
274
275 pub fn peer_viz_last_push(&self) -> Option<Instant> {
276 self.peer_viz_last_push
277 }
278
279 pub fn peer_viz_prev_push(&self) -> Option<Instant> {
280 self.peer_viz_prev_push
281 }
282
283 pub fn summaries_cache(&self) -> &HashMap<String, String> {
284 &self.summaries_cache
285 }
286
287 pub fn summaries_cache_mut(&mut self) -> &mut HashMap<String, String> {
288 &mut self.summaries_cache
289 }
290
291 pub fn request_delete(&mut self, idx: usize) {
294 self.pending_delete = Some(idx);
295 }
296
297 pub fn cancel_delete(&mut self) {
299 self.pending_delete = None;
300 }
301
302 pub fn ensure_lsof_poller(&mut self) {
306 if self.lsof.is_some() {
307 return;
308 }
309 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
310 let bind_ports: Arc<Mutex<Vec<(String, u16, u32)>>> = Arc::new(Mutex::new(Vec::new()));
311 let thread = crate::tunnel_live::spawn_lsof_poller(
312 bind_ports.clone(),
313 self.lsof_tx.clone(),
314 stop.clone(),
315 );
316 self.lsof = Some(LsofPollerHandle {
317 stop,
318 bind_ports,
319 thread: Some(thread),
320 });
321 log::debug!("[purple] Tunnel lsof poller started");
322 }
323
324 pub fn set_lsof_ports(&self, ports: Vec<(String, u16, u32)>) {
329 if let Some(handle) = &self.lsof
330 && let Ok(mut g) = handle.bind_ports.lock()
331 {
332 *g = ports;
333 }
334 }
335
336 pub fn poll(&mut self) -> Vec<TunnelExit> {
342 let now = Instant::now();
343 while let Ok(msg) = self.parser_rx.try_recv() {
346 if let Some(tunnel) = self.active.get_mut(&msg.alias) {
347 tunnel.live.record_event(msg.event);
348 }
349 }
350 let mut latest_lsof: Option<LsofMessage> = None;
354 while let Ok(msg) = self.lsof_rx.try_recv() {
355 latest_lsof = Some(msg);
356 }
357 if let Some(msg) = latest_lsof {
358 self.clients = msg.clients;
359 self.conflicts = msg.conflicts;
360 self.last_lsof_at = Some(msg.at);
361 self.push_peer_viz(now);
366 }
367 for tunnel in self.active.values_mut() {
370 tunnel.live.rotate_if_due(now);
371 }
372 let port_to_alias: HashMap<u16, String> = self
377 .lsof
378 .as_ref()
379 .and_then(|h| h.bind_ports.lock().ok().map(|g| g.clone()))
380 .map(|v| v.into_iter().map(|(a, p, _)| (p, a)).collect())
381 .unwrap_or_default();
382 let mut bps_per_alias: HashMap<String, (u64, u64, bool)> = HashMap::new();
388 for (port, peers) in &self.clients {
389 let Some(alias) = port_to_alias.get(port) else {
390 continue;
391 };
392 let entry = bps_per_alias
393 .entry(alias.clone())
394 .or_insert((0u64, 0u64, false));
395 for peer in peers {
396 entry.0 = entry.0.saturating_add(peer.current_rx_bps);
397 entry.1 = entry.1.saturating_add(peer.current_tx_bps);
398 if peer.last_sample_at.is_some() {
399 entry.2 = true;
400 }
401 }
402 }
403 for (alias, tunnel) in self.active.iter_mut() {
404 let (rx, tx, ready) = bps_per_alias.get(alias).copied().unwrap_or((0, 0, false));
405 tunnel.live.current_rx_bps = rx;
406 tunnel.live.current_tx_bps = tx;
407 tunnel.live.peak_rx_bps = tunnel.live.peak_rx_bps.max(rx);
408 tunnel.live.peak_tx_bps = tunnel.live.peak_tx_bps.max(tx);
409 if ready {
410 tunnel.live.last_throughput_at = Some(now);
411 }
412 }
413 let mut concurrent_per_alias: HashMap<String, u32> = HashMap::new();
420 for (port, peers) in &self.clients {
421 if let Some(alias) = port_to_alias.get(port) {
422 *concurrent_per_alias.entry(alias.clone()).or_insert(0) += peers.len() as u32;
423 }
424 }
425 for (alias, tunnel) in self.active.iter_mut() {
426 let lsof_count = concurrent_per_alias.get(alias).copied().unwrap_or(0);
427 let sample = lsof_count.max(tunnel.live.active_channels);
428 tunnel.live.sample_activity(sample);
429 }
430
431 if self.active.is_empty() {
432 return Vec::new();
433 }
434 let mut exited = Vec::new();
435 let mut to_remove = Vec::new();
436 for (alias, tunnel) in &mut self.active {
437 match tunnel.child.try_wait() {
438 Ok(Some(status)) => {
439 let exited_at = *tunnel.live.exited_at.get_or_insert(now);
442 if !tunnel.live.drained.load(Ordering::Relaxed)
443 && now.saturating_duration_since(exited_at) < EXIT_DRAIN_GRACE
444 {
445 continue;
446 }
447 let stderr_msg = tunnel
451 .live
452 .stderr_buffer
453 .lock()
454 .ok()
455 .and_then(|b| b.iter().rev().find(|s| !s.trim().is_empty()).cloned())
456 .map(|s| s.trim().to_string())
457 .filter(|s| !s.is_empty());
458 let exit_code = status.code().unwrap_or(-1);
459 if !status.success() {
460 log::error!(
461 "[external] Tunnel exited unexpectedly: alias={alias} exit={exit_code}"
462 );
463 if let Some(ref err) = stderr_msg {
464 log::debug!("[external] Tunnel stderr: {}", err.trim());
465 }
466 }
467 let last_exit_line = stderr_msg
468 .clone()
469 .unwrap_or_else(|| format!("exit code {}", exit_code));
470 tunnel.live.last_exit = Some((exit_code, last_exit_line));
471 tunnel.live.parser_stop.store(true, Ordering::Relaxed);
472 if tunnel.live.active_channels > 0 {
475 let close_now = ChannelEventKind::Close;
476 let ids: Vec<u32> = tunnel.live.channel_open.keys().copied().collect();
477 for id in ids {
478 tunnel.live.record_event(crate::tunnel_live::ChannelEvent {
479 at: now,
480 channel_id: id,
481 kind: close_now,
482 channel_kind: None,
483 opened_at: None,
484 });
485 }
486 }
487 let (msg, is_error) = if status.success() {
488 (format!("Tunnel for {} closed.", alias), false)
489 } else if let Some(err) = stderr_msg {
490 (format!("Tunnel for {}: {}", alias, err), true)
491 } else {
492 (
493 format!("Tunnel for {} exited with code {}.", alias, exit_code),
494 true,
495 )
496 };
497 exited.push(TunnelExit {
498 alias: alias.clone(),
499 message: msg,
500 is_error,
501 auth_refused: tunnel.live.auth_refused.load(Ordering::Relaxed),
502 authenticated: tunnel.live.authenticated.load(Ordering::Relaxed),
503 });
504 to_remove.push(alias.clone());
505 }
506 Ok(None) => {}
507 Err(e) => {
508 exited.push(TunnelExit {
509 alias: alias.clone(),
510 message: format!("Tunnel for {} lost: {}", alias, e),
511 is_error: true,
512 auth_refused: false,
513 authenticated: tunnel.live.authenticated.load(Ordering::Relaxed),
514 });
515 to_remove.push(alias.clone());
516 }
517 }
518 }
519 for alias in to_remove {
520 self.active.remove(&alias);
521 }
522 exited
523 }
524
525 pub fn push_peer_viz(&mut self, now: Instant) {
533 let mut live: HashSet<(u16, String)> = HashSet::new();
534 for (port, peers) in &self.clients {
535 for peer in peers {
536 let key = (*port, peer.src.clone());
537 live.insert(key.clone());
538 let combined = peer.current_rx_bps.saturating_add(peer.current_tx_bps);
539 let history = self
540 .peer_viz
541 .entry(key)
542 .or_insert_with(|| [0u64; PEER_VIZ_BUCKETS]);
543 history.rotate_left(1);
544 history[PEER_VIZ_BUCKETS - 1] = combined;
545 }
546 }
547 self.peer_viz.retain(|key, _| live.contains(key));
548 self.peer_viz_prev_push = self.peer_viz_last_push;
549 self.peer_viz_last_push = Some(now);
550 }
551
552 pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) {
557 let pre = self.demo_live_snapshots.len();
558 self.demo_live_snapshots
559 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
560 let dropped = pre.saturating_sub(self.demo_live_snapshots.len());
561 if dropped > 0 {
562 log::debug!(
563 "[purple] reload_hosts: dropped {dropped} orphan demo_live_snapshots entrie(s)"
564 );
565 }
566 }
567
568 pub fn migrate_alias(&mut self, old: &str, new: &str) {
574 if old == new {
575 return;
576 }
577 if let Some(t) = self.active.remove(old) {
578 self.active.insert(new.to_string(), t);
579 }
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
588 fn default_state_is_empty() {
589 let s = TunnelState::default();
590 assert!(s.list.is_empty());
591 assert!(s.active.is_empty());
592 assert!(s.pending_delete.is_none());
593 assert!(s.summaries_cache.is_empty());
594 }
595
596 #[test]
597 fn poll_on_empty_returns_empty_vec() {
598 let mut s = TunnelState::default();
602 let result = s.poll();
603 assert!(result.is_empty());
604 assert!(s.active.is_empty());
605 }
606
607 #[cfg(unix)]
608 #[test]
609 fn an_exit_waits_for_the_parser_to_read_the_last_lines() {
610 let mut s = TunnelState::default();
611 let child = std::process::Command::new("sh")
614 .arg("-c")
615 .arg("sleep 10 >&2 & exit 3")
616 .stderr(std::process::Stdio::piped())
617 .spawn()
618 .expect("spawn sh");
619 let tunnel = crate::tunnel::ActiveTunnel::spawn(
620 child,
621 "h",
622 s.parser_tx(),
623 crate::tunnel_live::AuthWatch::default(),
624 );
625 s.active.insert("h".into(), tunnel);
626 let deadline = Instant::now() + std::time::Duration::from_secs(5);
627 while s
628 .active
629 .get("h")
630 .is_some_and(|t| t.live.exited_at.is_none())
631 && Instant::now() < deadline
632 {
633 assert!(s.poll().is_empty(), "held back while the parser drains");
634 std::thread::sleep(std::time::Duration::from_millis(10));
635 }
636 assert!(s.active.contains_key("h"), "still waiting on the parser");
637 s.active.get_mut("h").expect("tunnel").live.exited_at = Some(Instant::now());
639 assert!(s.poll().is_empty(), "inside the grace the exit waits");
640 s.active.get_mut("h").expect("tunnel").live.exited_at =
642 Some(Instant::now() - EXIT_DRAIN_GRACE);
643 let exits = s.poll();
644 assert_eq!(exits.len(), 1);
645 assert!(exits[0].is_error, "exit 3 is an error");
646 assert!(!exits[0].auth_refused);
647 assert!(s.active.is_empty());
648 }
649
650 fn make_peer(src: &str, rx: u64, tx: u64) -> ClientPeer {
651 ClientPeer {
652 src: src.to_string(),
653 process: "curl".into(),
654 pid: 1234,
655 since: Instant::now(),
656 responsible_app: None,
657 current_rx_bps: rx,
658 current_tx_bps: tx,
659 bytes_rcvd: None,
660 bytes_sent: None,
661 last_sample_at: Some(Instant::now()),
662 }
663 }
664
665 #[test]
666 fn push_peer_viz_initialises_history_and_writes_combined_bps_to_rightmost_cell() {
667 let mut s = TunnelState::default();
668 s.clients
669 .insert(8080, vec![make_peer("127.0.0.1:1", 1_000, 500)]);
670 let now = Instant::now();
671 s.push_peer_viz(now);
672 let key = (8080u16, "127.0.0.1:1".to_string());
673 let history = s.peer_viz.get(&key).expect("entry created on first push");
674 assert_eq!(history[PEER_VIZ_BUCKETS - 1], 1_500);
675 for cell in &history[..PEER_VIZ_BUCKETS - 1] {
676 assert_eq!(*cell, 0);
677 }
678 assert_eq!(s.peer_viz_last_push, Some(now));
679 assert_eq!(s.peer_viz_prev_push, None);
680 }
681
682 #[test]
683 fn push_peer_viz_rotates_left_on_each_call() {
684 let mut s = TunnelState::default();
685 s.clients
686 .insert(8080, vec![make_peer("127.0.0.1:1", 100, 0)]);
687 let t0 = Instant::now();
688 s.push_peer_viz(t0);
689 if let Some(peers) = s.clients.get_mut(&8080) {
692 peers[0].current_rx_bps = 200;
693 }
694 let t1 = t0 + std::time::Duration::from_secs(2);
695 s.push_peer_viz(t1);
696 let key = (8080u16, "127.0.0.1:1".to_string());
697 let history = s.peer_viz.get(&key).expect("entry exists");
698 assert_eq!(history[PEER_VIZ_BUCKETS - 1], 200);
699 assert_eq!(history[PEER_VIZ_BUCKETS - 2], 100);
700 assert_eq!(s.peer_viz_last_push, Some(t1));
703 assert_eq!(s.peer_viz_prev_push, Some(t0));
704 }
705
706 #[test]
707 fn push_peer_viz_garbage_collects_disappeared_peers() {
708 let mut s = TunnelState::default();
709 s.clients.insert(8080, vec![make_peer("127.0.0.1:1", 0, 0)]);
710 let t0 = Instant::now();
711 s.push_peer_viz(t0);
712 assert!(
713 s.peer_viz
714 .contains_key(&(8080u16, "127.0.0.1:1".to_string()))
715 );
716 s.clients.clear();
718 s.push_peer_viz(t0 + std::time::Duration::from_secs(2));
719 assert!(s.peer_viz.is_empty());
720 }
721
722 #[test]
723 fn request_delete_sets_pending_delete_to_some_idx() {
724 let mut s = TunnelState::default();
725 s.request_delete(3);
726 assert_eq!(s.pending_delete, Some(3));
727 }
728
729 #[test]
730 fn cancel_delete_clears_pending_delete() {
731 let mut s = TunnelState::default();
732 s.pending_delete = Some(2);
733 s.cancel_delete();
734 assert!(s.pending_delete.is_none());
735 }
736
737 #[test]
738 fn request_delete_overwrites_existing_pending() {
739 let mut s = TunnelState::default();
740 s.pending_delete = Some(1);
741 s.request_delete(7);
742 assert_eq!(s.pending_delete, Some(7));
743 }
744
745 #[test]
746 fn cancel_delete_is_idempotent_on_empty_pending() {
747 let mut s = TunnelState::default();
748 s.cancel_delete();
749 s.cancel_delete();
750 assert!(s.pending_delete.is_none());
751 }
752
753 fn empty_snapshot() -> crate::tunnel_live::TunnelLiveSnapshot {
754 crate::tunnel_live::TunnelLiveSnapshot {
755 uptime_secs: 0,
756 active_channels: 0,
757 peak_concurrent: 0,
758 total_opens: 0,
759 idle_secs: 0,
760 rx_history: [0; crate::tunnel_live::HISTORY_BUCKETS],
761 tx_history: [0; crate::tunnel_live::HISTORY_BUCKETS],
762 current_rx_bps: 0,
763 current_tx_bps: 0,
764 peak_rx_bps: 0,
765 peak_tx_bps: 0,
766 throughput_ready: false,
767 clients: vec![],
768 events: vec![],
769 currently_open: vec![],
770 conflict: None,
771 last_exit: None,
772 }
773 }
774
775 #[test]
776 fn prune_orphans_drops_unknown_demo_snapshots() {
777 let mut s = TunnelState::default();
778 s.demo_live_snapshots
779 .insert("keep".to_string(), empty_snapshot());
780 s.demo_live_snapshots
781 .insert("drop".to_string(), empty_snapshot());
782
783 let valid: HashSet<&str> = ["keep"].into_iter().collect();
784 s.prune_orphans(&valid);
785
786 assert!(s.demo_live_snapshots.contains_key("keep"));
787 assert!(!s.demo_live_snapshots.contains_key("drop"));
788 }
789
790 #[test]
791 fn migrate_alias_is_noop_on_empty_active_map() {
792 let mut s = TunnelState::default();
798 s.migrate_alias("missing", "new");
799 assert!(s.active.is_empty());
800 s.migrate_alias("same", "same");
801 assert!(s.active.is_empty());
802 }
803
804 fn state_matching_baseline() -> TunnelState {
805 let mut s = TunnelState::default();
806 s.form.tunnel_type = crate::tunnel::TunnelType::Local;
807 s.form.bind_port = "8080".into();
808 s.form.remote_host = "db.internal".into();
809 s.form.remote_port = "5432".into();
810 s.form.bind_address = "127.0.0.1".into();
811 s.set_form_baseline(Some(TunnelFormBaseline {
812 tunnel_type: crate::tunnel::TunnelType::Local,
813 bind_port: "8080".into(),
814 remote_host: "db.internal".into(),
815 remote_port: "5432".into(),
816 bind_address: "127.0.0.1".into(),
817 }));
818 s
819 }
820
821 #[test]
822 fn form_is_dirty_is_false_without_a_baseline() {
823 let mut s = TunnelState::default();
824 s.form.bind_port = "9000".into();
825 assert!(!s.form_is_dirty());
826 }
827
828 #[test]
829 fn form_is_dirty_is_false_when_form_equals_baseline() {
830 assert!(!state_matching_baseline().form_is_dirty());
831 }
832
833 fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut TunnelForm)) {
834 let mut s = state_matching_baseline();
835 mutate(&mut s.form);
836 assert!(s.form_is_dirty(), "a change in {field} must read dirty");
837 }
838
839 #[test]
840 fn form_is_dirty_detects_a_change_in_each_field() {
841 assert_field_change_is_dirty("tunnel_type", |f| {
842 f.tunnel_type = crate::tunnel::TunnelType::Remote;
843 });
844 assert_field_change_is_dirty("bind_port", |f| f.bind_port.push('1'));
845 assert_field_change_is_dirty("remote_host", |f| f.remote_host.push('x'));
846 assert_field_change_is_dirty("remote_port", |f| f.remote_port.push('1'));
847 assert_field_change_is_dirty("bind_address", |f| f.bind_address.push('9'));
848 }
849}