Skip to main content

prt_core/core/
scanner.rs

1//! Port scanning, diffing, filtering, sorting, and export.
2//!
3//! This is the central business-logic module. The data pipeline is:
4//!
5//! ```text
6//! scan() → diff_entries() → sort_entries() → filter_indices() → UI
7//! ```
8//!
9//! Identity key for change tracking is `(local_port, pid)`.
10
11use crate::i18n;
12use crate::model::{EntryStatus, ExportFormat, PortEntry, SortColumn, SortState, TrackedEntry};
13use crate::platform;
14use anyhow::Result;
15use std::collections::{HashMap, HashSet};
16use std::time::{Duration, Instant};
17
18/// Scan all network ports visible to the current user.
19pub fn scan() -> Result<Vec<PortEntry>> {
20    platform::scan_ports()
21}
22
23/// Scan ports with cached sudo credentials (`sudo -n`).
24/// Falls back to unprivileged scan if credentials are not cached.
25pub fn scan_elevated() -> Result<Vec<PortEntry>> {
26    platform::scan_ports_elevated()
27}
28
29/// Scan ports using an explicit sudo password (`sudo -S`).
30/// The password is piped to stdin; no tty required.
31pub fn scan_with_sudo(password: &str) -> Result<Vec<PortEntry>> {
32    platform::scan_ports_with_sudo(password)
33}
34
35/// Compute entry diffs between the previous and current scan.
36///
37/// Returns a merged list where:
38/// - Entries present in `current` but not `prev` are [`EntryStatus::New`]
39/// - Entries present in both are [`EntryStatus::Unchanged`]
40/// - Entries in `prev` but not `current` are [`EntryStatus::Gone`]
41///
42/// Already-gone entries from `prev` are dropped (no double-gone).
43/// Identity key: `(local_port, pid)`.
44pub fn diff_entries(
45    prev: &[TrackedEntry],
46    current: Vec<PortEntry>,
47    now: Instant,
48) -> Vec<TrackedEntry> {
49    let current_keys: HashSet<(u16, u32)> = current
50        .iter()
51        .map(|e| (e.local_port(), e.process.pid))
52        .collect();
53
54    // HashMap for O(1) lookup + carry-forward of first_seen from prev entries.
55    let prev_map: HashMap<(u16, u32), &TrackedEntry> = prev
56        .iter()
57        .filter(|e| e.status != EntryStatus::Gone)
58        .map(|e| ((e.entry.local_port(), e.entry.process.pid), e))
59        .collect();
60
61    let mut result: Vec<TrackedEntry> = current
62        .into_iter()
63        .map(|entry| {
64            let key = (entry.local_port(), entry.process.pid);
65            let (status, first_seen) = if let Some(prev_e) = prev_map.get(&key) {
66                // Carry forward first_seen; if prev had None (pre-upgrade),
67                // start counting from now so aging kicks in eventually.
68                (EntryStatus::Unchanged, prev_e.first_seen.or(Some(now)))
69            } else {
70                // New entry — first_seen is now
71                (EntryStatus::New, Some(now))
72            };
73            TrackedEntry {
74                entry,
75                status,
76                seen_at: now,
77                first_seen,
78                suspicious: Vec::new(),
79                container_name: None,
80                service_name: None,
81            }
82        })
83        .collect();
84
85    for prev_entry in prev {
86        let key = (prev_entry.entry.local_port(), prev_entry.entry.process.pid);
87        if !current_keys.contains(&key) && prev_entry.status != EntryStatus::Gone {
88            result.push(TrackedEntry {
89                entry: prev_entry.entry.clone(),
90                status: EntryStatus::Gone,
91                seen_at: now,
92                first_seen: prev_entry.first_seen,
93                // Carry forward enrichment data so Gone entries retain
94                // their [!] tags and service names during the retention window.
95                suspicious: prev_entry.suspicious.clone(),
96                container_name: prev_entry.container_name.clone(),
97                service_name: prev_entry.service_name.clone(),
98            });
99        }
100    }
101
102    result
103}
104
105/// Format a duration as a human-readable short string.
106///
107/// Examples: "0s", "45s", "5m", "2h", "3d"
108pub fn format_duration(d: Duration) -> String {
109    let secs = d.as_secs();
110    if secs < 60 {
111        format!("{secs}s")
112    } else if secs < 3600 {
113        format!("{}m", secs / 60)
114    } else if secs < 86400 {
115        format!("{}h", secs / 3600)
116    } else {
117        format!("{}d", secs / 86400)
118    }
119}
120
121/// Sort entries in-place by the given column and direction.
122pub fn sort_entries(entries: &mut [TrackedEntry], state: &SortState) {
123    entries.sort_by(|a, b| {
124        let cmp = match state.column {
125            SortColumn::Port => a.entry.local_port().cmp(&b.entry.local_port()),
126            SortColumn::Service => {
127                // Sort None (unknown service) after all named services
128                let a_s = a.service_name.as_deref().unwrap_or("\u{FFFF}");
129                let b_s = b.service_name.as_deref().unwrap_or("\u{FFFF}");
130                a_s.cmp(b_s)
131            }
132            SortColumn::Protocol => a.entry.protocol.cmp(&b.entry.protocol),
133            SortColumn::State => a.entry.state.cmp(&b.entry.state),
134            SortColumn::Pid => a.entry.process.pid.cmp(&b.entry.process.pid),
135            SortColumn::ProcessName => a.entry.process.name.cmp(&b.entry.process.name),
136            SortColumn::User => a.entry.process.user.cmp(&b.entry.process.user),
137        };
138        if state.ascending {
139            cmp
140        } else {
141            cmp.reverse()
142        }
143    });
144}
145
146/// Returns `true` if the entry matches the query string.
147///
148/// Matches against: port number, process name, PID, protocol, state, user.
149/// All comparisons are case-insensitive.
150fn matches_query(e: &TrackedEntry, q: &str) -> bool {
151    // Special filter: "!" shows only suspicious entries
152    if q == "!" {
153        return !e.suspicious.is_empty();
154    }
155
156    e.entry.local_port().to_string().contains(q)
157        || e.entry.process.name.to_lowercase().contains(q)
158        || e.entry.process.pid.to_string().contains(q)
159        || e.entry.protocol.to_string().to_lowercase().contains(q)
160        || e.entry.state.to_string().to_lowercase().contains(q)
161        || e.entry
162            .process
163            .user
164            .as_deref()
165            .unwrap_or("")
166            .to_lowercase()
167            .contains(q)
168        || e.service_name
169            .as_deref()
170            .unwrap_or("")
171            .to_lowercase()
172            .contains(q)
173}
174
175/// Filter entries by query string, returning matching entries.
176/// Empty query returns all entries.
177#[cfg(test)]
178pub fn filter_entries<'a>(entries: &'a [TrackedEntry], query: &str) -> Vec<&'a TrackedEntry> {
179    if query.is_empty() {
180        return entries.iter().collect();
181    }
182    let q = query.to_lowercase();
183    entries.iter().filter(|e| matches_query(e, &q)).collect()
184}
185
186/// Filter entries by query, returning indices into the original slice.
187/// Empty query returns all indices `0..len`.
188pub fn filter_indices(entries: &[TrackedEntry], query: &str) -> Vec<usize> {
189    if query.is_empty() {
190        return (0..entries.len()).collect();
191    }
192    let q = query.to_lowercase();
193    entries
194        .iter()
195        .enumerate()
196        .filter(|(_, e)| matches_query(e, &q))
197        .map(|(i, _)| i)
198        .collect()
199}
200
201/// Export port entries to JSON or CSV string.
202///
203/// JSON uses `serde_json::to_string_pretty`.
204/// CSV includes a header row with all fields.
205pub fn export(entries: &[PortEntry], format: ExportFormat) -> Result<String> {
206    match format {
207        ExportFormat::Json => Ok(serde_json::to_string_pretty(entries)?),
208        ExportFormat::Csv => {
209            let mut buf = Vec::new();
210            {
211                let mut wtr = csv::Writer::from_writer(&mut buf);
212                wtr.write_record([
213                    "protocol",
214                    "local_addr",
215                    "remote_addr",
216                    "state",
217                    "pid",
218                    "process",
219                    "user",
220                    "parent_pid",
221                    "parent_name",
222                    "cmdline",
223                ])?;
224                for e in entries {
225                    wtr.write_record([
226                        &e.protocol.to_string(),
227                        &e.local_addr.to_string(),
228                        &e.remote_addr.map(|a| a.to_string()).unwrap_or_default(),
229                        &e.state.to_string(),
230                        &e.process.pid.to_string(),
231                        &e.process.name,
232                        e.process.user.as_deref().unwrap_or(""),
233                        &e.process
234                            .parent_pid
235                            .map(|p| p.to_string())
236                            .unwrap_or_default(),
237                        e.process.parent_name.as_deref().unwrap_or(""),
238                        e.process.cmdline.as_deref().unwrap_or(""),
239                    ])?;
240                }
241                wtr.flush()?;
242            }
243            Ok(String::from_utf8(buf)?)
244        }
245    }
246}
247
248/// Returns `true` if the current process is running as root (UID 0).
249pub fn is_root() -> bool {
250    nix::unistd::geteuid().is_root()
251}
252
253/// Build a text-based process tree for the given PID.
254///
255/// Shows up to 2 ancestor levels (grandparent → parent → process)
256/// and all network connections belonging to the process.
257pub fn build_process_tree(entries: &[TrackedEntry], pid: u32) -> Vec<String> {
258    let s = i18n::strings();
259    let entry = entries.iter().find(|e| e.entry.process.pid == pid);
260    let Some(entry) = entry else {
261        return vec![s.process_not_found.into()];
262    };
263
264    let mut lines = Vec::new();
265    let p = &entry.entry.process;
266
267    let mut ancestors: Vec<(u32, String)> = Vec::new();
268    if let (Some(ppid), Some(pname)) = (p.parent_pid, p.parent_name.as_ref()) {
269        ancestors.push((ppid, pname.clone()));
270        if let Some(parent_entry) = entries.iter().find(|e| e.entry.process.pid == ppid) {
271            if let (Some(gppid), Some(gpname)) = (
272                parent_entry.entry.process.parent_pid,
273                parent_entry.entry.process.parent_name.as_ref(),
274            ) {
275                ancestors.push((gppid, gpname.clone()));
276            }
277        }
278    }
279
280    ancestors.reverse();
281    for (i, (apid, aname)) in ancestors.iter().enumerate() {
282        let indent = "  ".repeat(i);
283        let connector = if i == 0 { "" } else { "└─ " };
284        lines.push(format!("{indent}{connector}{aname} ({apid})"));
285    }
286
287    let depth = ancestors.len();
288    let indent = "  ".repeat(depth);
289    let connector = if depth == 0 { "" } else { "└─ " };
290    let user_str = p.user.as_deref().unwrap_or("");
291    lines.push(format!(
292        "{indent}{connector}{} ({}) [{}]",
293        p.name, p.pid, user_str
294    ));
295
296    let child_indent = "  ".repeat(depth + 1);
297    for e in entries.iter().filter(|e| e.entry.process.pid == pid) {
298        let arrow = e
299            .entry
300            .remote_addr
301            .map(|a| format!(" → {a}"))
302            .unwrap_or_default();
303        lines.push(format!(
304            "{child_indent}├─ :{} {} {}{}",
305            e.entry.local_port(),
306            e.entry.protocol,
307            e.entry.state,
308            arrow,
309        ));
310    }
311
312    lines
313}
314
315/// Collect all entries belonging to a given PID.
316pub fn process_connections(entries: &[TrackedEntry], pid: u32) -> Vec<&TrackedEntry> {
317    entries
318        .iter()
319        .filter(|e| e.entry.process.pid == pid)
320        .collect()
321}
322
323/// Resolve a socket address to a human-readable interface description.
324///
325/// Returns localized strings for loopback, wildcard, or the raw IP.
326pub fn resolve_interface(addr: &std::net::SocketAddr) -> String {
327    let s = i18n::strings();
328    let ip = addr.ip();
329    if ip.is_loopback() {
330        s.iface_loopback.into()
331    } else if ip.is_unspecified() {
332        s.iface_all.into()
333    } else {
334        format!("{ip}")
335    }
336}
337
338// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
339// Tests
340// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::model::{ConnectionState, ProcessInfo, Protocol};
346    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
347
348    // ── Helpers ───────────────────────────────────────────────────
349
350    fn make_entry(port: u16, pid: u32, name: &str) -> PortEntry {
351        make_entry_full(
352            port,
353            pid,
354            name,
355            Protocol::Tcp,
356            ConnectionState::Listen,
357            None,
358        )
359    }
360
361    fn make_entry_full(
362        port: u16,
363        pid: u32,
364        name: &str,
365        proto: Protocol,
366        state: ConnectionState,
367        user: Option<&str>,
368    ) -> PortEntry {
369        PortEntry {
370            protocol: proto,
371            local_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
372            remote_addr: None,
373            state,
374            process: ProcessInfo {
375                pid,
376                name: name.to_string(),
377                path: None,
378                cmdline: None,
379                user: user.map(String::from),
380                parent_pid: None,
381                parent_name: None,
382            },
383        }
384    }
385
386    fn make_tracked(port: u16, pid: u32, name: &str, status: EntryStatus) -> TrackedEntry {
387        TrackedEntry {
388            entry: make_entry(port, pid, name),
389            status,
390            seen_at: Instant::now(),
391            first_seen: None,
392            suspicious: Vec::new(),
393            container_name: None,
394            service_name: None,
395        }
396    }
397
398    /// Create a TrackedEntry with a custom PortEntry (for sort/filter tests
399    /// that need non-default protocol/state/user).
400    fn make_tracked_custom(entry: PortEntry, status: EntryStatus) -> TrackedEntry {
401        TrackedEntry {
402            entry,
403            status,
404            seen_at: Instant::now(),
405            first_seen: None,
406            suspicious: Vec::new(),
407            container_name: None,
408            service_name: None,
409        }
410    }
411
412    // ── diff_entries: table-driven ────────────────────────────────
413
414    #[test]
415    fn diff_empty_prev_all_new() {
416        let current = vec![make_entry(80, 1, "nginx"), make_entry(443, 2, "nginx")];
417        let result = diff_entries(&[], current, Instant::now());
418        assert_eq!(result.len(), 2);
419        assert!(result.iter().all(|e| e.status == EntryStatus::New));
420    }
421
422    #[test]
423    fn diff_empty_current_all_gone() {
424        let prev = vec![make_tracked(80, 1, "nginx", EntryStatus::Unchanged)];
425        let result = diff_entries(&prev, vec![], Instant::now());
426        assert_eq!(result.len(), 1);
427        assert_eq!(result[0].status, EntryStatus::Gone);
428    }
429
430    #[test]
431    fn diff_unchanged_entries() {
432        let prev = vec![make_tracked(80, 1, "nginx", EntryStatus::Unchanged)];
433        let current = vec![make_entry(80, 1, "nginx")];
434        let result = diff_entries(&prev, current, Instant::now());
435        assert_eq!(result[0].status, EntryStatus::Unchanged);
436    }
437
438    #[test]
439    fn diff_already_gone_not_duplicated() {
440        let prev = vec![make_tracked(80, 1, "nginx", EntryStatus::Gone)];
441        let result = diff_entries(&prev, vec![], Instant::now());
442        assert_eq!(result.len(), 0);
443    }
444
445    #[test]
446    fn diff_mixed_new_unchanged_gone() {
447        let prev = vec![
448            make_tracked(80, 1, "nginx", EntryStatus::Unchanged),
449            make_tracked(443, 2, "apache", EntryStatus::Unchanged),
450        ];
451        // Port 80/pid1 stays, port 443/pid2 gone, port 8080/pid3 new
452        let current = vec![make_entry(80, 1, "nginx"), make_entry(8080, 3, "node")];
453        let result = diff_entries(&prev, current, Instant::now());
454        assert_eq!(result.len(), 3);
455
456        let statuses: Vec<(u16, EntryStatus)> = result
457            .iter()
458            .map(|e| (e.entry.local_port(), e.status))
459            .collect();
460        assert!(statuses.contains(&(80, EntryStatus::Unchanged)));
461        assert!(statuses.contains(&(8080, EntryStatus::New)));
462        assert!(statuses.contains(&(443, EntryStatus::Gone)));
463    }
464
465    #[test]
466    fn diff_same_port_different_pid_is_new() {
467        // Port 80 with pid 1 goes away, port 80 with pid 2 appears — different identity
468        let prev = vec![make_tracked(80, 1, "nginx", EntryStatus::Unchanged)];
469        let current = vec![make_entry(80, 2, "apache")];
470        let result = diff_entries(&prev, current, Instant::now());
471        assert_eq!(result.len(), 2);
472        assert!(result.iter().any(|e| e.status == EntryStatus::New));
473        assert!(result.iter().any(|e| e.status == EntryStatus::Gone));
474    }
475
476    #[test]
477    fn diff_same_pid_different_port() {
478        let prev = vec![make_tracked(80, 1, "nginx", EntryStatus::Unchanged)];
479        let current = vec![make_entry(443, 1, "nginx")];
480        let result = diff_entries(&prev, current, Instant::now());
481        assert!(result
482            .iter()
483            .any(|e| e.entry.local_port() == 443 && e.status == EntryStatus::New));
484        assert!(result
485            .iter()
486            .any(|e| e.entry.local_port() == 80 && e.status == EntryStatus::Gone));
487    }
488
489    // ── sort_entries: table-driven per column ─────────────────────
490
491    #[test]
492    fn sort_by_port_ascending() {
493        let mut entries = vec![
494            make_tracked(443, 1, "nginx", EntryStatus::Unchanged),
495            make_tracked(80, 2, "apache", EntryStatus::Unchanged),
496            make_tracked(8080, 3, "node", EntryStatus::Unchanged),
497        ];
498        sort_entries(
499            &mut entries,
500            &SortState {
501                column: SortColumn::Port,
502                ascending: true,
503            },
504        );
505        let ports: Vec<u16> = entries.iter().map(|e| e.entry.local_port()).collect();
506        assert_eq!(ports, vec![80, 443, 8080]);
507    }
508
509    #[test]
510    fn sort_by_port_descending() {
511        let mut entries = vec![
512            make_tracked(80, 1, "a", EntryStatus::Unchanged),
513            make_tracked(443, 2, "b", EntryStatus::Unchanged),
514            make_tracked(8080, 3, "c", EntryStatus::Unchanged),
515        ];
516        sort_entries(
517            &mut entries,
518            &SortState {
519                column: SortColumn::Port,
520                ascending: false,
521            },
522        );
523        let ports: Vec<u16> = entries.iter().map(|e| e.entry.local_port()).collect();
524        assert_eq!(ports, vec![8080, 443, 80]);
525    }
526
527    #[test]
528    fn sort_by_pid() {
529        let mut entries = vec![
530            make_tracked(80, 300, "c", EntryStatus::Unchanged),
531            make_tracked(443, 100, "a", EntryStatus::Unchanged),
532            make_tracked(8080, 200, "b", EntryStatus::Unchanged),
533        ];
534        sort_entries(
535            &mut entries,
536            &SortState {
537                column: SortColumn::Pid,
538                ascending: true,
539            },
540        );
541        let pids: Vec<u32> = entries.iter().map(|e| e.entry.process.pid).collect();
542        assert_eq!(pids, vec![100, 200, 300]);
543    }
544
545    #[test]
546    fn sort_by_process_name() {
547        let mut entries = vec![
548            make_tracked(80, 1, "nginx", EntryStatus::Unchanged),
549            make_tracked(443, 2, "apache", EntryStatus::Unchanged),
550            make_tracked(8080, 3, "caddy", EntryStatus::Unchanged),
551        ];
552        sort_entries(
553            &mut entries,
554            &SortState {
555                column: SortColumn::ProcessName,
556                ascending: true,
557            },
558        );
559        let names: Vec<&str> = entries
560            .iter()
561            .map(|e| e.entry.process.name.as_str())
562            .collect();
563        assert_eq!(names, vec!["apache", "caddy", "nginx"]);
564    }
565
566    #[test]
567    fn sort_by_protocol() {
568        let mut entries = vec![
569            make_tracked_custom(
570                make_entry_full(80, 1, "a", Protocol::Udp, ConnectionState::Unknown, None),
571                EntryStatus::Unchanged,
572            ),
573            make_tracked_custom(
574                make_entry_full(443, 2, "b", Protocol::Tcp, ConnectionState::Listen, None),
575                EntryStatus::Unchanged,
576            ),
577        ];
578        sort_entries(
579            &mut entries,
580            &SortState {
581                column: SortColumn::Protocol,
582                ascending: true,
583            },
584        );
585        assert_eq!(entries[0].entry.protocol, Protocol::Tcp);
586        assert_eq!(entries[1].entry.protocol, Protocol::Udp);
587    }
588
589    #[test]
590    fn sort_by_user() {
591        let mut entries = vec![
592            make_tracked_custom(
593                make_entry_full(
594                    80,
595                    1,
596                    "a",
597                    Protocol::Tcp,
598                    ConnectionState::Listen,
599                    Some("zoe"),
600                ),
601                EntryStatus::Unchanged,
602            ),
603            make_tracked_custom(
604                make_entry_full(
605                    443,
606                    2,
607                    "b",
608                    Protocol::Tcp,
609                    ConnectionState::Listen,
610                    Some("alice"),
611                ),
612                EntryStatus::Unchanged,
613            ),
614        ];
615        sort_entries(
616            &mut entries,
617            &SortState {
618                column: SortColumn::User,
619                ascending: true,
620            },
621        );
622        assert_eq!(entries[0].entry.process.user.as_deref(), Some("alice"));
623        assert_eq!(entries[1].entry.process.user.as_deref(), Some("zoe"));
624    }
625
626    #[test]
627    fn sort_empty_slice_no_panic() {
628        let mut entries: Vec<TrackedEntry> = vec![];
629        sort_entries(&mut entries, &SortState::default());
630        assert!(entries.is_empty());
631    }
632
633    // ── filter: table-driven ──────────────────────────────────────
634
635    #[test]
636    fn filter_empty_query_returns_all() {
637        let entries = vec![
638            make_tracked(80, 1, "nginx", EntryStatus::Unchanged),
639            make_tracked(443, 2, "apache", EntryStatus::Unchanged),
640        ];
641        assert_eq!(filter_entries(&entries, "").len(), 2);
642    }
643
644    #[test]
645    fn filter_by_port() {
646        let entries = vec![
647            make_tracked(80, 1, "nginx", EntryStatus::Unchanged),
648            make_tracked(443, 2, "apache", EntryStatus::Unchanged),
649            make_tracked(8080, 3, "node", EntryStatus::Unchanged),
650        ];
651        // "80" matches port 80 and 8080
652        assert_eq!(filter_entries(&entries, "80").len(), 2);
653    }
654
655    #[test]
656    fn filter_case_insensitive() {
657        let entries = vec![make_tracked(80, 1, "Nginx", EntryStatus::Unchanged)];
658        assert_eq!(filter_entries(&entries, "NGINX").len(), 1);
659        assert_eq!(filter_entries(&entries, "nginx").len(), 1);
660        assert_eq!(filter_entries(&entries, "nGiNx").len(), 1);
661    }
662
663    #[test]
664    fn filter_by_pid() {
665        let entries = vec![
666            make_tracked(80, 1234, "nginx", EntryStatus::Unchanged),
667            make_tracked(443, 5678, "apache", EntryStatus::Unchanged),
668        ];
669        assert_eq!(filter_entries(&entries, "1234").len(), 1);
670        assert_eq!(filter_entries(&entries, "5678").len(), 1);
671    }
672
673    #[test]
674    fn filter_by_protocol() {
675        let entries = vec![
676            make_tracked_custom(
677                make_entry_full(80, 1, "a", Protocol::Tcp, ConnectionState::Listen, None),
678                EntryStatus::Unchanged,
679            ),
680            make_tracked_custom(
681                make_entry_full(53, 2, "b", Protocol::Udp, ConnectionState::Unknown, None),
682                EntryStatus::Unchanged,
683            ),
684        ];
685        assert_eq!(filter_entries(&entries, "udp").len(), 1);
686        assert_eq!(filter_entries(&entries, "tcp").len(), 1);
687    }
688
689    #[test]
690    fn filter_by_state() {
691        let entries = vec![
692            make_tracked_custom(
693                make_entry_full(80, 1, "a", Protocol::Tcp, ConnectionState::Listen, None),
694                EntryStatus::Unchanged,
695            ),
696            make_tracked_custom(
697                make_entry_full(
698                    81,
699                    2,
700                    "b",
701                    Protocol::Tcp,
702                    ConnectionState::Established,
703                    None,
704                ),
705                EntryStatus::Unchanged,
706            ),
707        ];
708        assert_eq!(filter_entries(&entries, "listen").len(), 1);
709        assert_eq!(filter_entries(&entries, "established").len(), 1);
710    }
711
712    #[test]
713    fn filter_by_user() {
714        let entries = vec![
715            make_tracked_custom(
716                make_entry_full(
717                    80,
718                    1,
719                    "a",
720                    Protocol::Tcp,
721                    ConnectionState::Listen,
722                    Some("root"),
723                ),
724                EntryStatus::Unchanged,
725            ),
726            make_tracked_custom(
727                make_entry_full(
728                    81,
729                    2,
730                    "b",
731                    Protocol::Tcp,
732                    ConnectionState::Listen,
733                    Some("www-data"),
734                ),
735                EntryStatus::Unchanged,
736            ),
737        ];
738        assert_eq!(filter_entries(&entries, "root").len(), 1);
739        assert_eq!(filter_entries(&entries, "www").len(), 1);
740    }
741
742    #[test]
743    fn filter_no_match_returns_empty() {
744        let entries = vec![make_tracked(80, 1, "nginx", EntryStatus::Unchanged)];
745        assert_eq!(filter_entries(&entries, "zzz_no_match").len(), 0);
746    }
747
748    #[test]
749    fn filter_bang_shows_only_suspicious() {
750        use crate::model::SuspiciousReason;
751        let mut suspicious_entry = make_tracked(80, 1, "python3", EntryStatus::Unchanged);
752        suspicious_entry
753            .suspicious
754            .push(SuspiciousReason::ScriptOnSensitive);
755        let clean_entry = make_tracked(8080, 2, "nginx", EntryStatus::Unchanged);
756        let entries = vec![suspicious_entry, clean_entry];
757        let filtered = filter_entries(&entries, "!");
758        assert_eq!(filtered.len(), 1);
759        assert_eq!(filtered[0].entry.process.name, "python3");
760    }
761
762    #[test]
763    fn filter_indices_returns_correct_positions() {
764        let entries = vec![
765            make_tracked(80, 1, "nginx", EntryStatus::Unchanged),
766            make_tracked(443, 2, "apache", EntryStatus::Unchanged),
767            make_tracked(8080, 3, "nginx-proxy", EntryStatus::Unchanged),
768        ];
769        assert_eq!(filter_indices(&entries, "nginx"), vec![0, 2]);
770    }
771
772    #[test]
773    fn filter_indices_empty_query() {
774        let entries = vec![
775            make_tracked(80, 1, "a", EntryStatus::Unchanged),
776            make_tracked(443, 2, "b", EntryStatus::Unchanged),
777        ];
778        assert_eq!(filter_indices(&entries, ""), vec![0, 1]);
779    }
780
781    // ── export: table-driven ──────────────────────────────────────
782
783    #[test]
784    fn export_json_valid() {
785        let entries = vec![make_entry(80, 1, "nginx")];
786        let json = export(&entries, ExportFormat::Json).unwrap();
787        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
788        assert_eq!(parsed.as_array().unwrap().len(), 1);
789    }
790
791    #[test]
792    fn export_json_empty() {
793        let json = export(&[], ExportFormat::Json).unwrap();
794        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
795        assert_eq!(parsed.as_array().unwrap().len(), 0);
796    }
797
798    #[test]
799    fn export_csv_has_header_and_data() {
800        let entries = vec![make_entry(80, 1, "nginx")];
801        let csv_out = export(&entries, ExportFormat::Csv).unwrap();
802        let lines: Vec<&str> = csv_out.lines().collect();
803        assert!(lines.len() >= 2);
804        assert!(lines[0].contains("protocol"));
805        assert!(lines[0].contains("local_addr"));
806        assert!(lines[1].contains("nginx"));
807    }
808
809    #[test]
810    fn export_csv_empty() {
811        let csv_out = export(&[], ExportFormat::Csv).unwrap();
812        let lines: Vec<&str> = csv_out.lines().collect();
813        // Header only
814        assert_eq!(lines.len(), 1);
815        assert!(lines[0].contains("protocol"));
816    }
817
818    #[test]
819    fn export_csv_multiple_entries() {
820        let entries = vec![
821            make_entry(80, 1, "nginx"),
822            make_entry(443, 2, "apache"),
823            make_entry(8080, 3, "node"),
824        ];
825        let csv_out = export(&entries, ExportFormat::Csv).unwrap();
826        let lines: Vec<&str> = csv_out.lines().collect();
827        assert_eq!(lines.len(), 4); // header + 3 rows
828    }
829
830    #[test]
831    fn export_json_contains_all_fields() {
832        let entry = make_entry_full(
833            443,
834            42,
835            "nginx",
836            Protocol::Tcp,
837            ConnectionState::Established,
838            Some("www"),
839        );
840        let json = export(&[entry], ExportFormat::Json).unwrap();
841        assert!(json.contains("443"));
842        assert!(json.contains("42"));
843        assert!(json.contains("nginx"));
844        assert!(json.contains("Tcp"));
845        assert!(json.contains("Established"));
846        assert!(json.contains("www"));
847    }
848
849    // ── process_connections ────────────────────────────────────────
850
851    #[test]
852    fn process_connections_filters_by_pid() {
853        let entries = vec![
854            make_tracked(80, 1, "nginx", EntryStatus::Unchanged),
855            make_tracked(443, 1, "nginx", EntryStatus::Unchanged),
856            make_tracked(8080, 2, "node", EntryStatus::Unchanged),
857        ];
858        assert_eq!(process_connections(&entries, 1).len(), 2);
859        assert_eq!(process_connections(&entries, 2).len(), 1);
860        assert_eq!(process_connections(&entries, 999).len(), 0);
861    }
862
863    // ── resolve_interface ─────────────────────────────────────────
864
865    #[test]
866    fn resolve_interface_loopback() {
867        let addr: SocketAddr = "127.0.0.1:80".parse().unwrap();
868        let result = resolve_interface(&addr);
869        assert!(!result.is_empty());
870    }
871
872    #[test]
873    fn resolve_interface_wildcard() {
874        let addr: SocketAddr = "0.0.0.0:80".parse().unwrap();
875        let result = resolve_interface(&addr);
876        assert!(!result.is_empty());
877    }
878
879    #[test]
880    fn resolve_interface_specific_ip() {
881        let addr: SocketAddr = "192.168.1.1:80".parse().unwrap();
882        let result = resolve_interface(&addr);
883        assert!(result.contains("192.168.1.1"));
884    }
885
886    #[test]
887    fn resolve_interface_ipv6_loopback() {
888        let addr: SocketAddr = "[::1]:80".parse().unwrap();
889        let result = resolve_interface(&addr);
890        assert!(!result.is_empty());
891    }
892
893    #[test]
894    fn resolve_interface_ipv6_wildcard() {
895        let addr: SocketAddr = "[::]:80".parse().unwrap();
896        let result = resolve_interface(&addr);
897        assert!(!result.is_empty());
898    }
899
900    // ── diff_entries: first_seen carry-forward ─────────────────────
901
902    #[test]
903    fn diff_new_entry_gets_first_seen() {
904        let now = Instant::now();
905        let result = diff_entries(&[], vec![make_entry(80, 1, "nginx")], now);
906        assert_eq!(result[0].first_seen, Some(now));
907    }
908
909    #[test]
910    fn diff_unchanged_carries_first_seen_forward() {
911        let original_time = Instant::now();
912        let mut prev = make_tracked(80, 1, "nginx", EntryStatus::Unchanged);
913        prev.first_seen = Some(original_time);
914
915        let later = original_time + Duration::from_secs(10);
916        let result = diff_entries(&[prev], vec![make_entry(80, 1, "nginx")], later);
917        assert_eq!(result[0].status, EntryStatus::Unchanged);
918        assert_eq!(result[0].first_seen, Some(original_time));
919    }
920
921    #[test]
922    fn diff_gone_preserves_first_seen() {
923        let original_time = Instant::now();
924        let mut prev = make_tracked(80, 1, "nginx", EntryStatus::Unchanged);
925        prev.first_seen = Some(original_time);
926
927        let later = original_time + Duration::from_secs(10);
928        let result = diff_entries(&[prev], vec![], later);
929        assert_eq!(result[0].status, EntryStatus::Gone);
930        assert_eq!(result[0].first_seen, Some(original_time));
931    }
932
933    #[test]
934    fn sort_by_service_none_sorts_last() {
935        let mut entries = vec![
936            make_tracked(80, 1, "nginx", EntryStatus::Unchanged),
937            make_tracked(9999, 2, "custom", EntryStatus::Unchanged),
938            make_tracked(443, 3, "nginx", EntryStatus::Unchanged),
939        ];
940        entries[0].service_name = Some("http".into());
941        entries[1].service_name = None; // unknown
942        entries[2].service_name = Some("https".into());
943        sort_entries(
944            &mut entries,
945            &SortState {
946                column: SortColumn::Service,
947                ascending: true,
948            },
949        );
950        // Named services first, None last
951        assert_eq!(entries[0].service_name.as_deref(), Some("http"));
952        assert_eq!(entries[1].service_name.as_deref(), Some("https"));
953        assert_eq!(entries[2].service_name, None);
954    }
955
956    #[test]
957    fn diff_unchanged_with_none_first_seen_gets_now() {
958        // Simulates pre-upgrade entry with first_seen = None
959        let mut prev = make_tracked(80, 1, "nginx", EntryStatus::Unchanged);
960        prev.first_seen = None; // pre-upgrade: no first_seen
961
962        let now = Instant::now();
963        let result = diff_entries(&[prev], vec![make_entry(80, 1, "nginx")], now);
964        assert_eq!(result[0].status, EntryStatus::Unchanged);
965        // Should fill in `now` rather than leaving None forever
966        assert_eq!(result[0].first_seen, Some(now));
967    }
968
969    // ── format_duration ──────────────────────────────────────────
970
971    #[test]
972    fn format_duration_table() {
973        let cases = [
974            (Duration::from_secs(0), "0s"),
975            (Duration::from_secs(45), "45s"),
976            (Duration::from_secs(60), "1m"),
977            (Duration::from_secs(300), "5m"),
978            (Duration::from_secs(3600), "1h"),
979            (Duration::from_secs(7200), "2h"),
980            (Duration::from_secs(86400), "1d"),
981            (Duration::from_secs(259200), "3d"),
982        ];
983        for (dur, expected) in cases {
984            assert_eq!(format_duration(dur), expected, "duration {:?}", dur);
985        }
986    }
987
988    // ── build_process_tree ────────────────────────────────────────
989
990    #[test]
991    fn build_tree_unknown_pid() {
992        let entries = vec![make_tracked(80, 1, "nginx", EntryStatus::Unchanged)];
993        let tree = build_process_tree(&entries, 999);
994        assert_eq!(tree.len(), 1); // "process not found"
995    }
996
997    #[test]
998    fn build_tree_single_process() {
999        let entries = vec![make_tracked(80, 1, "nginx", EntryStatus::Unchanged)];
1000        let tree = build_process_tree(&entries, 1);
1001        assert!(tree.len() >= 2); // process line + connection line
1002        assert!(tree[0].contains("nginx"));
1003        assert!(tree[1].contains(":80"));
1004    }
1005
1006    #[test]
1007    fn build_tree_with_parent() {
1008        let mut entry = make_tracked(80, 2, "worker", EntryStatus::Unchanged);
1009        entry.entry.process.parent_pid = Some(1);
1010        entry.entry.process.parent_name = Some("master".into());
1011        let entries = vec![entry];
1012        let tree = build_process_tree(&entries, 2);
1013        assert!(tree.iter().any(|l| l.contains("master")));
1014        assert!(tree.iter().any(|l| l.contains("worker")));
1015    }
1016
1017    #[test]
1018    fn build_tree_multiple_ports() {
1019        let entries = vec![
1020            make_tracked(80, 1, "nginx", EntryStatus::Unchanged),
1021            make_tracked(443, 1, "nginx", EntryStatus::Unchanged),
1022        ];
1023        let tree = build_process_tree(&entries, 1);
1024        assert!(tree.iter().any(|l| l.contains(":80")));
1025        assert!(tree.iter().any(|l| l.contains(":443")));
1026    }
1027}