#![allow(clippy::must_use_candidate)]
use std::net::SocketAddr;
use crate::vortix_core::ports::socket_audit::{
SocketAudit, SocketAuditError, SocketAuditResult, SocketProtocol, SocketSnapshot,
};
use crate::vortix_process::{CommandSpec, PrivilegeReq};
#[derive(Debug, Clone, Copy, Default)]
pub struct LsofSocketAudit;
impl SocketAudit for LsofSocketAudit {
fn snapshot() -> SocketAuditResult<Vec<SocketSnapshot>> {
let spec = CommandSpec::oneshot("lsof", vec!["-i".into(), "-P".into(), "-n".into()])
.privilege(PrivilegeReq::None);
let output = crate::vortix_process::run_to_output(spec)
.map_err(|e| SocketAuditError::CommandFailed(format!("lsof: {e}")))?;
if !output.status.success() {
return Err(SocketAuditError::CommandFailed(format!(
"lsof exited {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let body = String::from_utf8_lossy(&output.stdout);
Ok(parse_lsof_output(&body))
}
}
pub fn parse_lsof_output(body: &str) -> Vec<SocketSnapshot> {
let mut out = Vec::new();
for line in body.lines().skip(1) {
if let Some(snap) = parse_one_line(line) {
out.push(snap);
}
}
out
}
fn parse_one_line(line: &str) -> Option<SocketSnapshot> {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 9 {
return None;
}
let command = fields[0].to_string();
let pid: u32 = fields[1].parse().ok()?;
let type_field = fields[4]; let node_field = fields[7]; let name_field = fields[8..].join(" ");
let protocol = match (type_field, node_field) {
("IPv4", "TCP") => SocketProtocol::Tcp,
("IPv4", "UDP") => SocketProtocol::Udp,
("IPv6", "TCP") => SocketProtocol::Tcp6,
("IPv6", "UDP") => SocketProtocol::Udp6,
_ => return None,
};
let core: String = match name_field.split_once(" (") {
Some((before, _)) => before.to_string(),
None => name_field.clone(),
};
let (local_str, remote_opt) = match core.split_once("->") {
Some((l, r)) => (l.to_string(), Some(r.to_string())),
None => (core, None),
};
let local = parse_lsof_addr(&local_str)?;
let remote = remote_opt.and_then(|r| parse_lsof_addr(&r));
Some(SocketSnapshot {
pid,
command,
local,
remote,
protocol,
interface: None,
})
}
fn parse_lsof_addr(s: &str) -> Option<SocketAddr> {
let s = s.trim();
let cleaned = if let Some(start) = s.find('%') {
let end = s[start..].find([']', ':']).unwrap_or(s.len() - start);
let mut owned = String::with_capacity(s.len());
owned.push_str(&s[..start]);
owned.push_str(&s[start + end..]);
owned
} else {
s.to_string()
};
if let Some(port) = cleaned.strip_prefix("*:") {
let port: u16 = port.parse().ok()?;
return Some(SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
port,
));
}
cleaned.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_output_returns_empty() {
let body = "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n";
assert!(parse_lsof_output(body).is_empty());
}
#[test]
fn listening_socket_no_remote() {
let body = "\
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
nc 55555 alice 3u IPv4 0xABC 0t0 TCP *:8080 (LISTEN)
";
let snaps = parse_lsof_output(body);
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].pid, 55555);
assert_eq!(snaps[0].command, "nc");
assert_eq!(
snaps[0].local,
"0.0.0.0:8080".parse::<SocketAddr>().unwrap()
);
assert_eq!(snaps[0].remote, None);
}
#[test]
fn established_tcp_socket_with_remote() {
let body = "\
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
curl 12345 alice 5u IPv4 0xABC 0t0 TCP 127.0.0.1:54321->8.8.8.8:443 (ESTABLISHED)
";
let snaps = parse_lsof_output(body);
assert_eq!(snaps.len(), 1);
let s = &snaps[0];
assert_eq!(s.pid, 12345);
assert_eq!(s.local, "127.0.0.1:54321".parse::<SocketAddr>().unwrap());
assert_eq!(s.remote, Some("8.8.8.8:443".parse::<SocketAddr>().unwrap()));
assert_eq!(s.protocol, SocketProtocol::Tcp);
}
#[test]
fn ipv6_with_zone_id_strips_it() {
let body = "\
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
proc 77777 alice 4u IPv6 0xABC 0t0 TCP [fe80::1%en0]:443->[fe80::2%en0]:54321 (ESTABLISHED)
";
let snaps = parse_lsof_output(body);
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].protocol, SocketProtocol::Tcp6);
}
#[test]
fn malformed_line_skipped_not_aborted() {
let body = "\
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
GARBAGE
curl 12345 alice 5u IPv4 0xABC 0t0 TCP 127.0.0.1:54321->8.8.8.8:443 (ESTABLISHED)
";
let snaps = parse_lsof_output(body);
assert_eq!(snaps.len(), 1);
}
}