Skip to main content

prt_core/core/
session.rs

1//! Session management — encapsulates the refresh/diff/retain/sort cycle.
2//!
3//! [`Session`] is the single point of truth for scan state. The TUI
4//! delegates all data operations to it, keeping the UI layer thin.
5
6use crate::config::PrtConfig;
7use crate::core::bandwidth::BandwidthTracker;
8use crate::core::{container, scanner, suspicious};
9use crate::i18n;
10use crate::known_ports;
11use crate::model::{EntryStatus, SortState, TrackedEntry, GONE_RETENTION};
12use std::time::Instant;
13
14/// Shared scan session state used by the TUI app.
15///
16/// Encapsulates the full refresh cycle:
17/// `scan → diff → enrich → retain(gone) → sort`
18///
19/// Stores sudo password (if elevated) so subsequent refreshes
20/// can re-authenticate without user interaction.
21pub struct Session {
22    pub entries: Vec<TrackedEntry>,
23    pub sort: SortState,
24    pub is_elevated: bool,
25    pub is_root: bool,
26    pub config: PrtConfig,
27    pub bandwidth: BandwidthTracker,
28    sudo_password: Option<String>,
29}
30
31impl Default for Session {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl Session {
38    pub fn new() -> Self {
39        Self {
40            entries: Vec::new(),
41            sort: SortState::default(),
42            is_elevated: false,
43            is_root: scanner::is_root(),
44            config: crate::config::load_config(),
45            bandwidth: BandwidthTracker::new(),
46            sudo_password: None,
47        }
48    }
49
50    /// Run a scan cycle: scan → diff → enrich → retain → sort.
51    pub fn refresh(&mut self) -> Result<(), String> {
52        let scan_result = if let Some(password) = &self.sudo_password {
53            scanner::scan_with_sudo(password)
54        } else {
55            scanner::scan()
56        };
57
58        match scan_result {
59            Ok(new_entries) => {
60                let now = Instant::now();
61                self.entries = scanner::diff_entries(&self.entries, new_entries, now);
62
63                // ── Enrichment pipeline ──────────────────────────
64                self.enrich_service_names();
65                self.enrich_suspicious();
66                self.enrich_containers();
67
68                self.entries.retain(|e| {
69                    e.status != EntryStatus::Gone || now.duration_since(e.seen_at) < GONE_RETENTION
70                });
71
72                // ── Metrics ─────────────────────────────────────
73                self.bandwidth.sample();
74
75                scanner::sort_entries(&mut self.entries, &self.sort);
76                Ok(())
77            }
78            Err(e) => {
79                let s = i18n::strings();
80                Err(s.fmt_scan_error(&e.to_string()))
81            }
82        }
83    }
84
85    /// Populate service_name on all entries from the known ports DB.
86    /// Skips Gone entries — they carry forward enrichment from `diff_entries`.
87    fn enrich_service_names(&mut self) {
88        for entry in &mut self.entries {
89            if entry.status != EntryStatus::Gone {
90                entry.service_name =
91                    known_ports::lookup(entry.entry.local_port(), &self.config.known_ports);
92            }
93        }
94    }
95
96    /// Run suspicious-connection heuristics on all entries.
97    /// Skips Gone entries — they carry forward enrichment from `diff_entries`.
98    fn enrich_suspicious(&mut self) {
99        for entry in &mut self.entries {
100            if entry.status != EntryStatus::Gone {
101                entry.suspicious = suspicious::check(&entry.entry);
102            }
103        }
104    }
105
106    /// Resolve Docker/Podman container names for all active entries.
107    /// One batched CLI call per refresh cycle. Skips Gone entries.
108    fn enrich_containers(&mut self) {
109        let pids: Vec<u32> = self
110            .entries
111            .iter()
112            .filter(|e| e.status != EntryStatus::Gone)
113            .map(|e| e.entry.process.pid)
114            .collect();
115
116        let names = container::resolve_container_names(&pids);
117
118        for entry in &mut self.entries {
119            if entry.status != EntryStatus::Gone {
120                entry.container_name = names.get(&entry.entry.process.pid).cloned();
121            }
122        }
123    }
124
125    /// Get cached sudo password (if elevated). Used by firewall block.
126    pub fn sudo_password(&self) -> Option<&str> {
127        self.sudo_password.as_deref()
128    }
129
130    pub fn filtered_indices(&self, query: &str) -> Vec<usize> {
131        scanner::filter_indices(&self.entries, query)
132    }
133
134    /// Attempt sudo elevation with password. Returns status message.
135    pub fn try_sudo(&mut self, password: &str) -> String {
136        let s = i18n::strings();
137        match scanner::scan_with_sudo(password) {
138            Ok(new_entries) => {
139                self.sudo_password = Some(password.to_string());
140                self.is_elevated = true;
141                self.is_root = true;
142                let now = Instant::now();
143                self.entries = scanner::diff_entries(&self.entries, new_entries, now);
144
145                // Remove expired Gone entries (same as refresh)
146                self.entries.retain(|e| {
147                    e.status != EntryStatus::Gone || e.seen_at.elapsed() < GONE_RETENTION
148                });
149
150                // ── Enrichment pipeline (same as refresh) ───────
151                self.enrich_service_names();
152                self.enrich_suspicious();
153                self.enrich_containers();
154
155                // ── Metrics ─────────────────────────────────────
156                self.bandwidth.sample();
157
158                scanner::sort_entries(&mut self.entries, &self.sort);
159                s.sudo_elevated.to_string()
160            }
161            Err(e) => {
162                self.sudo_password = None;
163                self.is_elevated = false;
164                let msg = e.to_string();
165                if msg.contains("incorrect password") || msg.contains("Sorry") {
166                    s.sudo_wrong_password.to_string()
167                } else {
168                    s.fmt_sudo_error(&msg)
169                }
170            }
171        }
172    }
173}