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/// Top-level section. Tab / Shift+Tab cycles between sections.
232///
233/// `Connections` is the default: port table + bottom Details panel.
234/// `Processes` shows the selected entry's process detail / topology.
235/// `Ssh` aggregates SSH hosts and active tunnels.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
237pub enum ViewMode {
238    /// Connections: port table + Details panel.
239    #[default]
240    Connections,
241    /// Processes: Detail / Topology sub-tabs.
242    Processes,
243    /// SSH: Hosts / Tunnels sub-tabs.
244    Ssh,
245}
246
247impl ViewMode {
248    pub const ALL: &[ViewMode] = &[ViewMode::Connections, ViewMode::Processes, ViewMode::Ssh];
249
250    fn index(self) -> usize {
251        Self::ALL
252            .iter()
253            .position(|&m| m == self)
254            .expect("all ViewMode variants must be listed in ALL")
255    }
256
257    pub fn next(self) -> Self {
258        Self::ALL[(self.index() + 1) % Self::ALL.len()]
259    }
260
261    pub fn prev(self) -> Self {
262        Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()]
263    }
264}
265
266/// Sub-tab inside the Processes section.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
268pub enum ProcessesTab {
269    #[default]
270    Detail,
271    Topology,
272}
273
274impl ProcessesTab {
275    pub const ALL: &[ProcessesTab] = &[ProcessesTab::Detail, ProcessesTab::Topology];
276
277    pub fn next(self) -> Self {
278        match self {
279            ProcessesTab::Detail => ProcessesTab::Topology,
280            ProcessesTab::Topology => ProcessesTab::Detail,
281        }
282    }
283
284    pub fn prev(self) -> Self {
285        self.next()
286    }
287}
288
289/// Action that can be invoked on the currently selected entry.
290///
291/// Triggered via the Space-key contextual menu (and a small set of direct
292/// shortcuts: `K` for Kill, `c` for Copy).
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum ActionItem {
295    Kill,
296    Copy,
297    CopyPid,
298    BlockIp,
299    Trace,
300    Forward,
301}
302
303/// Sub-tab inside the SSH section.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
305pub enum SshTab {
306    #[default]
307    Hosts,
308    Tunnels,
309}
310
311impl SshTab {
312    pub const ALL: &[SshTab] = &[SshTab::Hosts, SshTab::Tunnels];
313
314    pub fn next(self) -> Self {
315        match self {
316            SshTab::Hosts => SshTab::Tunnels,
317            SshTab::Tunnels => SshTab::Hosts,
318        }
319    }
320
321    pub fn prev(self) -> Self {
322        self.next()
323    }
324}
325
326/// Output format for CLI export mode (`--export`).
327///
328/// Note: this enum intentionally does not derive `clap::ValueEnum` to keep
329/// `prt-core` free of CLI dependencies. The binary crate wraps it with
330/// `CliExportFormat`.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum ExportFormat {
333    Json,
334    Csv,
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
341
342    fn make_process() -> ProcessInfo {
343        ProcessInfo {
344            pid: 1,
345            name: "test".into(),
346            path: None,
347            cmdline: None,
348            user: None,
349            parent_pid: None,
350            parent_name: None,
351        }
352    }
353
354    #[test]
355    fn local_port_returns_port_from_addr() {
356        let entry = PortEntry {
357            protocol: Protocol::Tcp,
358            local_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080),
359            remote_addr: None,
360            state: ConnectionState::Listen,
361            process: make_process(),
362        };
363        assert_eq!(entry.local_port(), 8080);
364    }
365
366    #[test]
367    fn sort_state_default_is_port_ascending() {
368        let s = SortState::default();
369        assert_eq!(s.column, SortColumn::Port);
370        assert!(s.ascending);
371    }
372
373    #[test]
374    fn sort_state_toggle_same_column_flips_direction() {
375        let mut s = SortState::default();
376        s.toggle(SortColumn::Port);
377        assert!(!s.ascending);
378        s.toggle(SortColumn::Port);
379        assert!(s.ascending);
380    }
381
382    #[test]
383    fn sort_state_toggle_different_column_resets_ascending() {
384        let mut s = SortState::default();
385        s.toggle(SortColumn::Port);
386        s.toggle(SortColumn::Pid);
387        assert_eq!(s.column, SortColumn::Pid);
388        assert!(s.ascending);
389    }
390
391    #[test]
392    fn protocol_display() {
393        assert_eq!(Protocol::Tcp.to_string(), "TCP");
394        assert_eq!(Protocol::Udp.to_string(), "UDP");
395    }
396
397    #[test]
398    fn connection_state_display() {
399        let cases = [
400            (ConnectionState::Listen, "LISTEN"),
401            (ConnectionState::Established, "ESTABLISHED"),
402            (ConnectionState::TimeWait, "TIME_WAIT"),
403            (ConnectionState::CloseWait, "CLOSE_WAIT"),
404            (ConnectionState::SynSent, "SYN_SENT"),
405            (ConnectionState::SynRecv, "SYN_RECV"),
406            (ConnectionState::FinWait1, "FIN_WAIT1"),
407            (ConnectionState::FinWait2, "FIN_WAIT2"),
408            (ConnectionState::Closing, "CLOSING"),
409            (ConnectionState::LastAck, "LAST_ACK"),
410            (ConnectionState::Closed, "CLOSED"),
411            (ConnectionState::Unknown, "UNKNOWN"),
412        ];
413        for (state, expected) in cases {
414            assert_eq!(state.to_string(), expected, "state {:?}", state);
415        }
416    }
417
418    #[test]
419    fn view_mode_default_is_connections() {
420        assert_eq!(ViewMode::default(), ViewMode::Connections);
421    }
422
423    #[test]
424    fn view_mode_next_prev_cycle() {
425        let cases = [
426            (ViewMode::Connections, ViewMode::Processes),
427            (ViewMode::Processes, ViewMode::Ssh),
428            (ViewMode::Ssh, ViewMode::Connections),
429        ];
430        for (from, expected) in cases {
431            assert_eq!(from.next(), expected);
432            assert_eq!(expected.prev(), from);
433        }
434    }
435
436    #[test]
437    fn processes_tab_cycle() {
438        assert_eq!(ProcessesTab::Detail.next(), ProcessesTab::Topology);
439        assert_eq!(ProcessesTab::Topology.next(), ProcessesTab::Detail);
440        assert_eq!(ProcessesTab::default(), ProcessesTab::Detail);
441    }
442
443    #[test]
444    fn ssh_tab_cycle() {
445        assert_eq!(SshTab::Hosts.next(), SshTab::Tunnels);
446        assert_eq!(SshTab::Tunnels.next(), SshTab::Hosts);
447        assert_eq!(SshTab::default(), SshTab::Hosts);
448    }
449
450    // ── SortState toggle table ────────────────────────────────────
451
452    #[test]
453    fn sort_state_toggle_all_columns() {
454        let columns = [
455            SortColumn::Port,
456            SortColumn::Service,
457            SortColumn::Protocol,
458            SortColumn::State,
459            SortColumn::Pid,
460            SortColumn::ProcessName,
461            SortColumn::User,
462        ];
463        for col in columns {
464            let mut s = SortState::default();
465            s.toggle(col);
466            if col == SortColumn::Port {
467                assert!(!s.ascending, "toggling same column should flip");
468            } else {
469                assert_eq!(s.column, col);
470                assert!(s.ascending, "switching to {:?} should be ascending", col);
471            }
472        }
473    }
474}