1use 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
18pub fn scan() -> Result<Vec<PortEntry>> {
20 platform::scan_ports()
21}
22
23pub fn scan_elevated() -> Result<Vec<PortEntry>> {
26 platform::scan_ports_elevated()
27}
28
29pub fn scan_with_sudo(password: &str) -> Result<Vec<PortEntry>> {
32 platform::scan_ports_with_sudo(password)
33}
34
35pub 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 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 (EntryStatus::Unchanged, prev_e.first_seen.or(Some(now)))
69 } else {
70 (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 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
105pub 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
121pub 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 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
146fn matches_query(e: &TrackedEntry, q: &str) -> bool {
151 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#[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
186pub 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
201pub 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
248pub fn is_root() -> bool {
250 nix::unistd::geteuid().is_root()
251}
252
253pub 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
315pub 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
323pub 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#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::model::{ConnectionState, ProcessInfo, Protocol};
346 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
347
348 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 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 #[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 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 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 #[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 #[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 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 #[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 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); }
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 #[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 #[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 #[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; 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 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 let mut prev = make_tracked(80, 1, "nginx", EntryStatus::Unchanged);
960 prev.first_seen = None; 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 assert_eq!(result[0].first_seen, Some(now));
967 }
968
969 #[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 #[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); }
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); 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}