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/// Tracks whether sudo authentication was completed successfully.
20/// Subsequent refreshes use cached sudo credentials (`sudo -n`)
21/// without storing the password in memory.
22pub struct Session {
23    pub entries: Vec<TrackedEntry>,
24    pub sort: SortState,
25    pub is_elevated: bool,
26    pub is_root: bool,
27    pub config: PrtConfig,
28    pub bandwidth: BandwidthTracker,
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        }
47    }
48
49    /// Run a scan cycle: scan → diff → enrich → retain → sort.
50    pub fn refresh(&mut self) -> Result<(), String> {
51        let was_elevated = self.is_elevated;
52        if was_elevated {
53            self.sync_elevation_state(scanner::has_elevated_access());
54        }
55
56        let scan_result = if self.is_elevated {
57            scanner::scan_elevated()
58        } else {
59            scanner::scan()
60        };
61
62        match scan_result {
63            Ok(new_entries) => {
64                if was_elevated {
65                    self.sync_elevation_state(scanner::has_elevated_access());
66                }
67                let now = Instant::now();
68                self.entries = scanner::diff_entries(&self.entries, new_entries, now);
69
70                // ── Enrichment pipeline ──────────────────────────
71                self.enrich_service_names();
72                self.enrich_suspicious();
73                self.enrich_containers();
74
75                self.entries.retain(|e| {
76                    e.status != EntryStatus::Gone || now.duration_since(e.seen_at) < GONE_RETENTION
77                });
78
79                // ── Metrics ─────────────────────────────────────
80                self.bandwidth.sample();
81
82                scanner::sort_entries(&mut self.entries, &self.sort);
83                Ok(())
84            }
85            Err(e) => {
86                let s = i18n::strings();
87                Err(s.fmt_scan_error(&e.to_string()))
88            }
89        }
90    }
91
92    /// Populate service_name on all entries from the known ports DB.
93    /// Skips Gone entries — they carry forward enrichment from `diff_entries`.
94    fn enrich_service_names(&mut self) {
95        for entry in &mut self.entries {
96            if entry.status != EntryStatus::Gone {
97                entry.service_name =
98                    known_ports::lookup(entry.entry.local_port(), &self.config.known_ports);
99            }
100        }
101    }
102
103    /// Run suspicious-connection heuristics on all entries.
104    /// Skips Gone entries — they carry forward enrichment from `diff_entries`.
105    fn enrich_suspicious(&mut self) {
106        for entry in &mut self.entries {
107            if entry.status != EntryStatus::Gone {
108                entry.suspicious = suspicious::check(&entry.entry);
109            }
110        }
111    }
112
113    /// Resolve Docker/Podman container names for all active entries.
114    /// One batched CLI call per refresh cycle. Skips Gone entries.
115    fn enrich_containers(&mut self) {
116        let pids: Vec<u32> = self
117            .entries
118            .iter()
119            .filter(|e| e.status != EntryStatus::Gone)
120            .map(|e| e.entry.process.pid)
121            .collect();
122
123        let names = container::resolve_container_names(&pids);
124
125        for entry in &mut self.entries {
126            if entry.status != EntryStatus::Gone {
127                entry.container_name = names.get(&entry.entry.process.pid).cloned();
128            }
129        }
130    }
131
132    pub fn filtered_indices(&self, query: &str) -> Vec<usize> {
133        scanner::filter_indices(&self.entries, query)
134    }
135
136    fn sync_elevation_state(&mut self, has_elevated_access: bool) {
137        if self.is_elevated && !has_elevated_access {
138            self.is_elevated = false;
139            self.is_root = scanner::is_root();
140        }
141    }
142
143    /// Attempt sudo elevation with password. Returns status message.
144    pub fn try_sudo(&mut self, password: &str) -> String {
145        let s = i18n::strings();
146        match scanner::scan_with_sudo(password) {
147            Ok(new_entries) => {
148                self.is_elevated = true;
149                self.is_root = true;
150                let now = Instant::now();
151                self.entries = scanner::diff_entries(&self.entries, new_entries, now);
152
153                // Remove expired Gone entries (same as refresh)
154                self.entries.retain(|e| {
155                    e.status != EntryStatus::Gone || e.seen_at.elapsed() < GONE_RETENTION
156                });
157
158                // ── Enrichment pipeline (same as refresh) ───────
159                self.enrich_service_names();
160                self.enrich_suspicious();
161                self.enrich_containers();
162
163                // ── Metrics ─────────────────────────────────────
164                self.bandwidth.sample();
165
166                scanner::sort_entries(&mut self.entries, &self.sort);
167                s.sudo_elevated.to_string()
168            }
169            Err(e) => {
170                self.is_elevated = false;
171                let msg = e.to_string();
172                if msg.contains("incorrect password") || msg.contains("Sorry") {
173                    s.sudo_wrong_password.to_string()
174                } else {
175                    s.fmt_sudo_error(&msg)
176                }
177            }
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn sync_elevation_state_clears_expired_sudo_cache() {
188        let mut session = Session::new();
189        session.is_elevated = true;
190        session.is_root = true;
191
192        session.sync_elevation_state(false);
193
194        assert!(!session.is_elevated);
195        assert_eq!(session.is_root, scanner::is_root());
196    }
197}