1use 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
20pub fn scan() -> Result<Vec<PortEntry>> {
22 platform::scan_ports()
23}
24
25pub fn scan_elevated() -> Result<Vec<PortEntry>> {
28 platform::scan_ports_elevated()
29}
30
31pub fn has_elevated_access() -> bool {
33 platform::has_elevated_access()
34}
35
36pub fn scan_with_sudo(password: &str) -> Result<Vec<PortEntry>> {
39 platform::scan_ports_with_sudo(password)
40}
41
42pub 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 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 (EntryStatus::Unchanged, prev_e.first_seen.or(Some(now)))
73 } else {
74 (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 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
131pub 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
147pub 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 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
172fn matches_query(e: &TrackedEntry, q: &str) -> bool {
177 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#[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
212pub 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
227pub 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
290pub fn is_root() -> bool {
292 nix::unistd::geteuid().is_root()
293}
294
295pub 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
357pub 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
365pub 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#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::model::{ConnectionState, ProcessInfo, Protocol};
388 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
389
390 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 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 #[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 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 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 #[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 #[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 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 #[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 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); }
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 #[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 #[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 #[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; 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 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 let mut prev = make_tracked(80, 1, "nginx", EntryStatus::Unchanged);
1069 prev.first_seen = None; 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 assert_eq!(result[0].first_seen, Some(now));
1076 }
1077
1078 #[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 #[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); }
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); 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}