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, 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, 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}
292
293/// Output format for CLI export mode (`--export`).
294///
295/// Note: this enum intentionally does not derive `clap::ValueEnum` to keep
296/// `prt-core` free of CLI dependencies. The binary crate wraps it with
297/// `CliExportFormat`.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum ExportFormat {
300    Json,
301    Csv,
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
308
309    fn make_process() -> ProcessInfo {
310        ProcessInfo {
311            pid: 1,
312            name: "test".into(),
313            path: None,
314            cmdline: None,
315            user: None,
316            parent_pid: None,
317            parent_name: None,
318        }
319    }
320
321    #[test]
322    fn local_port_returns_port_from_addr() {
323        let entry = PortEntry {
324            protocol: Protocol::Tcp,
325            local_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080),
326            remote_addr: None,
327            state: ConnectionState::Listen,
328            process: make_process(),
329        };
330        assert_eq!(entry.local_port(), 8080);
331    }
332
333    #[test]
334    fn sort_state_default_is_port_ascending() {
335        let s = SortState::default();
336        assert_eq!(s.column, SortColumn::Port);
337        assert!(s.ascending);
338    }
339
340    #[test]
341    fn sort_state_toggle_same_column_flips_direction() {
342        let mut s = SortState::default();
343        s.toggle(SortColumn::Port);
344        assert!(!s.ascending);
345        s.toggle(SortColumn::Port);
346        assert!(s.ascending);
347    }
348
349    #[test]
350    fn sort_state_toggle_different_column_resets_ascending() {
351        let mut s = SortState::default();
352        s.toggle(SortColumn::Port);
353        s.toggle(SortColumn::Pid);
354        assert_eq!(s.column, SortColumn::Pid);
355        assert!(s.ascending);
356    }
357
358    #[test]
359    fn protocol_display() {
360        assert_eq!(Protocol::Tcp.to_string(), "TCP");
361        assert_eq!(Protocol::Udp.to_string(), "UDP");
362    }
363
364    #[test]
365    fn connection_state_display() {
366        let cases = [
367            (ConnectionState::Listen, "LISTEN"),
368            (ConnectionState::Established, "ESTABLISHED"),
369            (ConnectionState::TimeWait, "TIME_WAIT"),
370            (ConnectionState::CloseWait, "CLOSE_WAIT"),
371            (ConnectionState::SynSent, "SYN_SENT"),
372            (ConnectionState::SynRecv, "SYN_RECV"),
373            (ConnectionState::FinWait1, "FIN_WAIT1"),
374            (ConnectionState::FinWait2, "FIN_WAIT2"),
375            (ConnectionState::Closing, "CLOSING"),
376            (ConnectionState::LastAck, "LAST_ACK"),
377            (ConnectionState::Closed, "CLOSED"),
378            (ConnectionState::Unknown, "UNKNOWN"),
379        ];
380        for (state, expected) in cases {
381            assert_eq!(state.to_string(), expected, "state {:?}", state);
382        }
383    }
384
385    // ── DetailTab cycling ─────────────────────────────────────────
386
387    #[test]
388    fn detail_tab_next_cycles_forward() {
389        let cases = [
390            (DetailTab::Tree, DetailTab::Interface),
391            (DetailTab::Interface, DetailTab::Connection),
392            (DetailTab::Connection, DetailTab::Tree),
393        ];
394        for (from, expected) in cases {
395            assert_eq!(from.next(), expected, "next of {:?}", from);
396        }
397    }
398
399    #[test]
400    fn detail_tab_prev_cycles_backward() {
401        let cases = [
402            (DetailTab::Tree, DetailTab::Connection),
403            (DetailTab::Interface, DetailTab::Tree),
404            (DetailTab::Connection, DetailTab::Interface),
405        ];
406        for (from, expected) in cases {
407            assert_eq!(from.prev(), expected, "prev of {:?}", from);
408        }
409    }
410
411    #[test]
412    fn detail_tab_next_prev_roundtrip() {
413        for tab in DetailTab::ALL {
414            let tab = *tab;
415            assert_eq!(tab.next().prev(), tab, "roundtrip {:?}", tab);
416            assert_eq!(tab.prev().next(), tab, "reverse roundtrip {:?}", tab);
417        }
418    }
419
420    #[test]
421    fn detail_tab_all_contains_every_variant() {
422        let variant_count = {
423            let mut n = 0u8;
424            for tab in DetailTab::ALL {
425                match tab {
426                    DetailTab::Tree => n += 1,
427                    DetailTab::Interface => n += 1,
428                    DetailTab::Connection => n += 1,
429                }
430            }
431            n as usize
432        };
433        assert_eq!(
434            DetailTab::ALL.len(),
435            variant_count,
436            "ALL must list every DetailTab variant exactly once"
437        );
438    }
439
440    #[test]
441    fn detail_tab_index_matches_position() {
442        for (i, &tab) in DetailTab::ALL.iter().enumerate() {
443            assert_eq!(tab.index(), i, "index of {:?}", tab);
444        }
445    }
446
447    #[test]
448    fn detail_tab_key_label() {
449        assert_eq!(DetailTab::Tree.key_label(), "1");
450        assert_eq!(DetailTab::Interface.key_label(), "2");
451        assert_eq!(DetailTab::Connection.key_label(), "3");
452    }
453
454    #[test]
455    fn view_mode_default_is_table() {
456        assert_eq!(ViewMode::default(), ViewMode::Table);
457    }
458
459    // ── SortState toggle table ────────────────────────────────────
460
461    #[test]
462    fn sort_state_toggle_all_columns() {
463        let columns = [
464            SortColumn::Port,
465            SortColumn::Service,
466            SortColumn::Protocol,
467            SortColumn::State,
468            SortColumn::Pid,
469            SortColumn::ProcessName,
470            SortColumn::User,
471        ];
472        for col in columns {
473            let mut s = SortState::default();
474            s.toggle(col);
475            if col == SortColumn::Port {
476                assert!(!s.ascending, "toggling same column should flip");
477            } else {
478                assert_eq!(s.column, col);
479                assert!(s.ascending, "switching to {:?} should be ascending", col);
480            }
481        }
482    }
483}