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