Skip to main content

prt_core/
model.rs

1//! Core data types for the port monitor.
2//!
3//! This module defines all shared types used across the scanner, session,
4//! and UI layers. The key types are:
5//!
6//! - [`PortEntry`] — a single network connection with process info
7//! - [`TrackedEntry`] — a `PortEntry` enriched with change-tracking status
8//! - [`SortState`] — current sort column and direction
9//! - [`ExportFormat`] — output format for CLI export
10
11use serde::Serialize;
12use std::fmt;
13use std::net::SocketAddr;
14use std::path::PathBuf;
15use std::time::{Duration, Instant};
16
17/// Auto-refresh interval for the TUI. The UI polls for new scan data
18/// at this rate.
19pub const TICK_RATE: Duration = Duration::from_secs(2);
20
21/// How long a "Gone" entry stays visible before removal.
22/// Gives the user time to notice a connection disappeared.
23pub const GONE_RETENTION: Duration = Duration::from_secs(5);
24
25/// Network transport protocol.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
27pub enum Protocol {
28    Tcp,
29    Udp,
30}
31
32impl fmt::Display for Protocol {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Protocol::Tcp => write!(f, "TCP"),
36            Protocol::Udp => write!(f, "UDP"),
37        }
38    }
39}
40
41/// TCP connection state.
42///
43/// Matches standard TCP FSM states plus `Unknown` for UDP or unparsable states.
44/// Display format uses uppercase with underscores (e.g. `TIME_WAIT`).
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
46pub enum ConnectionState {
47    Listen,
48    Established,
49    TimeWait,
50    CloseWait,
51    SynSent,
52    SynRecv,
53    FinWait1,
54    FinWait2,
55    Closing,
56    LastAck,
57    Closed,
58    Unknown,
59}
60
61impl fmt::Display for ConnectionState {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        let s = match self {
64            ConnectionState::Listen => "LISTEN",
65            ConnectionState::Established => "ESTABLISHED",
66            ConnectionState::TimeWait => "TIME_WAIT",
67            ConnectionState::CloseWait => "CLOSE_WAIT",
68            ConnectionState::SynSent => "SYN_SENT",
69            ConnectionState::SynRecv => "SYN_RECV",
70            ConnectionState::FinWait1 => "FIN_WAIT1",
71            ConnectionState::FinWait2 => "FIN_WAIT2",
72            ConnectionState::Closing => "CLOSING",
73            ConnectionState::LastAck => "LAST_ACK",
74            ConnectionState::Closed => "CLOSED",
75            ConnectionState::Unknown => "UNKNOWN",
76        };
77        write!(f, "{s}")
78    }
79}
80
81/// Information about the process that owns a network connection.
82///
83/// Fields like `path`, `cmdline`, `parent_pid`, and `parent_name` are
84/// populated via a secondary `ps` call (macOS) or `/proc` (Linux)
85/// and may be `None` if the process has exited or access is denied.
86#[derive(Debug, Clone, Serialize)]
87pub struct ProcessInfo {
88    /// Process ID.
89    pub pid: u32,
90    /// Short process name (e.g. "nginx").
91    pub name: String,
92    /// Full path to the executable, if available.
93    pub path: Option<PathBuf>,
94    /// Full command line, if available.
95    pub cmdline: Option<String>,
96    /// Username of the process owner.
97    pub user: Option<String>,
98    /// Parent process ID.
99    pub parent_pid: Option<u32>,
100    /// Parent process name, resolved from `parent_pid`.
101    pub parent_name: Option<String>,
102}
103
104/// A single network port entry.
105///
106/// This is the fundamental data unit — one row in the port table.
107/// Identity key for change tracking is `(local_port, pid)`.
108#[derive(Debug, Clone, Serialize)]
109pub struct PortEntry {
110    /// Transport protocol (TCP or UDP).
111    pub protocol: Protocol,
112    /// Local socket address (ip:port).
113    pub local_addr: SocketAddr,
114    /// Remote socket address, if connected.
115    pub remote_addr: Option<SocketAddr>,
116    /// Connection state (LISTEN, ESTABLISHED, etc.).
117    pub state: ConnectionState,
118    /// Process that owns this connection.
119    pub process: ProcessInfo,
120}
121
122impl PortEntry {
123    /// Returns the local port number.
124    pub fn local_port(&self) -> u16 {
125        self.local_addr.port()
126    }
127}
128
129/// Change-tracking status for a port entry between scan cycles.
130///
131/// Used by [`crate::core::scanner::diff_entries`] to compute what changed.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum EntryStatus {
134    /// Entry existed in previous scan and still exists.
135    Unchanged,
136    /// Entry is new since last scan.
137    New,
138    /// Entry disappeared since last scan. Will be retained for
139    /// [`GONE_RETENTION`] seconds before removal.
140    Gone,
141}
142
143/// A [`PortEntry`] with change-tracking metadata and enrichment data.
144///
145/// The `status` field indicates whether the entry is new, unchanged,
146/// or gone since the last scan. `seen_at` records when the status
147/// was last updated.
148///
149/// Enrichment fields (`first_seen`, `suspicious`, `container_name`,
150/// `service_name`) are populated lazily by the corresponding modules
151/// after the diff step. They use `Option` / `Vec` to be zero-cost
152/// when the corresponding feature is not active.
153#[derive(Debug, Clone)]
154pub struct TrackedEntry {
155    /// The underlying port entry.
156    pub entry: PortEntry,
157    /// Current change status.
158    pub status: EntryStatus,
159    /// When this status was assigned.
160    pub seen_at: Instant,
161
162    // ── Enrichment fields ────────────────────────────────────────
163    /// When this connection was first observed (carried forward across
164    /// scan cycles for Unchanged entries). Used for connection aging.
165    pub first_seen: Option<Instant>,
166    /// Suspicious activity reasons detected by heuristics.
167    pub suspicious: Vec<SuspiciousReason>,
168    /// Docker/Podman container name, if the process runs inside one.
169    pub container_name: Option<String>,
170    /// Well-known service name for the port (e.g. "http", "ssh").
171    pub service_name: Option<String>,
172}
173
174/// Reason why a connection was flagged as suspicious.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum SuspiciousReason {
177    /// Non-root process listening on a privileged port (< 1024).
178    NonRootPrivileged,
179    /// Scripting language (python, perl, ruby, node) on a sensitive port.
180    ScriptOnSensitive,
181    /// Root process making outgoing connection to a high port.
182    RootHighPortOutgoing,
183}
184
185/// Column by which the port table can be sorted.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum SortColumn {
188    Port,
189    Service,
190    Protocol,
191    State,
192    Pid,
193    ProcessName,
194    User,
195}
196
197/// Current sorting configuration: which column and direction.
198///
199/// Toggle behavior: pressing the same column flips direction;
200/// pressing a different column switches to it ascending.
201#[derive(Debug, Clone, Copy)]
202pub struct SortState {
203    /// Column to sort by.
204    pub column: SortColumn,
205    /// `true` = ascending (A→Z, 0→9), `false` = descending.
206    pub ascending: bool,
207}
208
209impl Default for SortState {
210    fn default() -> Self {
211        Self {
212            column: SortColumn::Port,
213            ascending: true,
214        }
215    }
216}
217
218impl SortState {
219    /// Toggle sorting: same column flips direction, different column
220    /// switches to ascending.
221    pub fn toggle(&mut self, col: SortColumn) {
222        if self.column == col {
223            self.ascending = !self.ascending;
224        } else {
225            self.column = col;
226            self.ascending = true;
227        }
228    }
229}
230
231/// Tab in the detail panel below the port table (selected process info).
232///
233/// These tabs only appear in the bottom split panel when `ViewMode::Table`
234/// is active and `show_details` is true.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum DetailTab {
237    /// Process tree view.
238    Tree,
239    /// Network interface info.
240    Interface,
241    /// Connection details.
242    Connection,
243}
244
245impl DetailTab {
246    /// All tabs in display order.
247    pub const ALL: &[DetailTab] = &[DetailTab::Tree, DetailTab::Interface, DetailTab::Connection];
248
249    /// Position of this tab in [`Self::ALL`] (0-based).
250    pub fn index(self) -> usize {
251        Self::ALL
252            .iter()
253            .position(|&t| t == self)
254            .expect("all DetailTab variants must be listed in ALL")
255    }
256
257    /// Cycle to the next tab (wraps around).
258    pub fn next(self) -> Self {
259        Self::ALL[(self.index() + 1) % Self::ALL.len()]
260    }
261
262    /// Cycle to the previous tab (wraps around).
263    pub fn prev(self) -> Self {
264        Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()]
265    }
266
267    /// One-based label used for the tab bar and key dispatch, e.g. `"1"`.
268    pub fn key_label(self) -> String {
269        (self.index() + 1).to_string()
270    }
271}
272
273/// Main view mode — what occupies the primary screen area.
274///
275/// `Table` is the default: shows the port table (+ optional bottom detail panel).
276/// Other modes are fullscreen and replace the table entirely.
277/// Press `Esc` to return to `Table` from any other mode.
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
279pub enum ViewMode {
280    /// Normal port table (default view).
281    #[default]
282    Table,
283    /// Fullscreen bar chart: connections per process.
284    Chart,
285    /// Fullscreen network topology: process → port → remote.
286    Topology,
287    /// Fullscreen process detail: cwd, env, files, CPU/RAM, connections.
288    ProcessDetail,
289    /// Fullscreen network namespace grouping (Linux only).
290    Namespaces,
291    /// Fullscreen list of saved SSH hosts (from `~/.ssh/config` + prt config).
292    SshHosts,
293    /// Fullscreen SSH tunnels manager.
294    Tunnels,
295}
296
297/// Output format for CLI export mode (`--export`).
298///
299/// Note: this enum intentionally does not derive `clap::ValueEnum` to keep
300/// `prt-core` free of CLI dependencies. The binary crate wraps it with
301/// `CliExportFormat`.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum ExportFormat {
304    Json,
305    Csv,
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
312
313    fn make_process() -> ProcessInfo {
314        ProcessInfo {
315            pid: 1,
316            name: "test".into(),
317            path: None,
318            cmdline: None,
319            user: None,
320            parent_pid: None,
321            parent_name: None,
322        }
323    }
324
325    #[test]
326    fn local_port_returns_port_from_addr() {
327        let entry = PortEntry {
328            protocol: Protocol::Tcp,
329            local_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080),
330            remote_addr: None,
331            state: ConnectionState::Listen,
332            process: make_process(),
333        };
334        assert_eq!(entry.local_port(), 8080);
335    }
336
337    #[test]
338    fn sort_state_default_is_port_ascending() {
339        let s = SortState::default();
340        assert_eq!(s.column, SortColumn::Port);
341        assert!(s.ascending);
342    }
343
344    #[test]
345    fn sort_state_toggle_same_column_flips_direction() {
346        let mut s = SortState::default();
347        s.toggle(SortColumn::Port);
348        assert!(!s.ascending);
349        s.toggle(SortColumn::Port);
350        assert!(s.ascending);
351    }
352
353    #[test]
354    fn sort_state_toggle_different_column_resets_ascending() {
355        let mut s = SortState::default();
356        s.toggle(SortColumn::Port);
357        s.toggle(SortColumn::Pid);
358        assert_eq!(s.column, SortColumn::Pid);
359        assert!(s.ascending);
360    }
361
362    #[test]
363    fn protocol_display() {
364        assert_eq!(Protocol::Tcp.to_string(), "TCP");
365        assert_eq!(Protocol::Udp.to_string(), "UDP");
366    }
367
368    #[test]
369    fn connection_state_display() {
370        let cases = [
371            (ConnectionState::Listen, "LISTEN"),
372            (ConnectionState::Established, "ESTABLISHED"),
373            (ConnectionState::TimeWait, "TIME_WAIT"),
374            (ConnectionState::CloseWait, "CLOSE_WAIT"),
375            (ConnectionState::SynSent, "SYN_SENT"),
376            (ConnectionState::SynRecv, "SYN_RECV"),
377            (ConnectionState::FinWait1, "FIN_WAIT1"),
378            (ConnectionState::FinWait2, "FIN_WAIT2"),
379            (ConnectionState::Closing, "CLOSING"),
380            (ConnectionState::LastAck, "LAST_ACK"),
381            (ConnectionState::Closed, "CLOSED"),
382            (ConnectionState::Unknown, "UNKNOWN"),
383        ];
384        for (state, expected) in cases {
385            assert_eq!(state.to_string(), expected, "state {:?}", state);
386        }
387    }
388
389    // ── DetailTab cycling ─────────────────────────────────────────
390
391    #[test]
392    fn detail_tab_next_cycles_forward() {
393        let cases = [
394            (DetailTab::Tree, DetailTab::Interface),
395            (DetailTab::Interface, DetailTab::Connection),
396            (DetailTab::Connection, DetailTab::Tree),
397        ];
398        for (from, expected) in cases {
399            assert_eq!(from.next(), expected, "next of {:?}", from);
400        }
401    }
402
403    #[test]
404    fn detail_tab_prev_cycles_backward() {
405        let cases = [
406            (DetailTab::Tree, DetailTab::Connection),
407            (DetailTab::Interface, DetailTab::Tree),
408            (DetailTab::Connection, DetailTab::Interface),
409        ];
410        for (from, expected) in cases {
411            assert_eq!(from.prev(), expected, "prev of {:?}", from);
412        }
413    }
414
415    #[test]
416    fn detail_tab_next_prev_roundtrip() {
417        for tab in DetailTab::ALL {
418            let tab = *tab;
419            assert_eq!(tab.next().prev(), tab, "roundtrip {:?}", tab);
420            assert_eq!(tab.prev().next(), tab, "reverse roundtrip {:?}", tab);
421        }
422    }
423
424    #[test]
425    fn detail_tab_all_contains_every_variant() {
426        let variant_count = {
427            let mut n = 0u8;
428            for tab in DetailTab::ALL {
429                match tab {
430                    DetailTab::Tree => n += 1,
431                    DetailTab::Interface => n += 1,
432                    DetailTab::Connection => n += 1,
433                }
434            }
435            n as usize
436        };
437        assert_eq!(
438            DetailTab::ALL.len(),
439            variant_count,
440            "ALL must list every DetailTab variant exactly once"
441        );
442    }
443
444    #[test]
445    fn detail_tab_index_matches_position() {
446        for (i, &tab) in DetailTab::ALL.iter().enumerate() {
447            assert_eq!(tab.index(), i, "index of {:?}", tab);
448        }
449    }
450
451    #[test]
452    fn detail_tab_key_label() {
453        assert_eq!(DetailTab::Tree.key_label(), "1");
454        assert_eq!(DetailTab::Interface.key_label(), "2");
455        assert_eq!(DetailTab::Connection.key_label(), "3");
456    }
457
458    #[test]
459    fn view_mode_default_is_table() {
460        assert_eq!(ViewMode::default(), ViewMode::Table);
461    }
462
463    // ── SortState toggle table ────────────────────────────────────
464
465    #[test]
466    fn sort_state_toggle_all_columns() {
467        let columns = [
468            SortColumn::Port,
469            SortColumn::Service,
470            SortColumn::Protocol,
471            SortColumn::State,
472            SortColumn::Pid,
473            SortColumn::ProcessName,
474            SortColumn::User,
475        ];
476        for col in columns {
477            let mut s = SortState::default();
478            s.toggle(col);
479            if col == SortColumn::Port {
480                assert!(!s.ascending, "toggling same column should flip");
481            } else {
482                assert_eq!(s.column, col);
483                assert!(s.ascending, "switching to {:?} should be ascending", col);
484            }
485        }
486    }
487}