diff --git a/Cargo.toml b/Cargo.toml index 7a5c3ad0..5758a3b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,12 +93,16 @@ unicode-width = "0.1" # System tables (processes(), and the family to follow). Optional so a build # can drop the OS-inspection dependencies entirely. sysinfo = { version = "0.32", optional = true } +# sockets(): the OS's own socket tables with owning pids - GetExtendedTcpTable +# on Windows, netlink sock_diag plus /proc/*/fd on Linux, libproc on macOS. +# No name resolution anywhere, which is what makes `netstat -a` slow. +netstat2 = { version = "0.11", optional = true } [features] default = ["redis-cache", "system-tables"] redis-cache = ["dep:redis"] -# `processes()` and friends: OS inspection as table generators. -system-tables = ["dep:sysinfo"] +# `processes()`, `sockets()` and friends: OS inspection as table generators. +system-tables = ["dep:sysinfo", "dep:netstat2"] [dev-dependencies] tempfile = "3.8" diff --git a/docs/SYSTEM_TABLES.md b/docs/SYSTEM_TABLES.md index 6e9f86b3..36e7ba43 100644 --- a/docs/SYSTEM_TABLES.md +++ b/docs/SYSTEM_TABLES.md @@ -173,34 +173,109 @@ recursive query. See S3. a pid, a status inside the normalised set, and no empty strings where NULL is meant. -## S2 — `sockets()` / listening ports -- **Status:** 🔴 OPEN — the next one +## S2 — `sockets()` +- **Status:** 🟢 DONE 2026-09-17 +- **Where:** `src/sql/generators/system.rs` beside S1, registered the same way; + worked queries in `examples/system_sockets.sql` +- **Depends on:** `netstat2` 0.11, optional, under the same `system-tables` + feature - **Wanted:** *which process is holding that port*, which is the question that - started this. One row per connection: protocol, local address and port, - remote address and port, state, and the owning `pid`. -- **Design:** `netstat2` is the obvious crate — socket-to-pid is `/proc` on - Linux, `GetExtendedTcpTable` on Windows, `libproc` on macOS, which is exactly - the sort of thing not to hand-write. Same feature flag, same NULL rules. -- **Expect partial answers without privileges.** On both Windows and Linux the - owning pid of another user's socket is often unavailable. The row must still - appear with a NULL `pid`, since the socket itself is real. -- **Joins to S1 on `pid`** through the CTE form above, which is the first real - demonstration that these compose. -- **Two row-shape decisions to take up front (added 2026-09-13):** - - **One socket can belong to several processes** — `netstat2` reports a list - of associated pids (a listener shared across a fork, an inherited handle). - One row per *(socket, pid)*, with a single NULL-pid row when the list is - empty, keeps `JOIN processes() ON pid` a plain equi-join. A socket with no - visible owner must still appear, per the NULL rule. - - **UDP has no remote end and no state.** Same columns for both protocols; - `remote_address`, `remote_port` and `state` are NULL for UDP rather than - `0.0.0.0` / `0` / an invented state. -- **Columns, proposed:** `protocol` (`tcp`/`udp`), `family` (`ipv4`/`ipv6`), - `local_address`, `local_port`, `remote_address`, `remote_port`, `state`, `pid`. - TCP state names are standard (RFC 793), so normalise them to one spelling — - `LISTEN`, `ESTABLISHED`, `TIME_WAIT`… — the way S1 normalised `status`. -- **Check the crate first:** confirm `netstat2` is maintained and builds on all - three targets with `--no-default-features` still compiling without it. + started this. +- **Columns**, one row per *(socket, owning pid)*, the same everywhere: + + | Column | Type | Notes | + |---|---|---| + | `protocol` | String | `tcp` / `udp` | + | `family` | String | `ipv4` / `ipv6` | + | `local_address` | String | the literal IP, never a hostname | + | `local_port` | Integer | | + | `remote_address` | String | NULL for UDP and for a TCP socket with no far end | + | `remote_port` | Integer | NULL on the same rows | + | `state` | String | RFC 793 name; NULL for UDP | + | `pid` | Integer | NULL when no owner is visible | + +- **No name resolution, which is why this is fast.** The worry going in was + `netstat -a` on Windows, which takes seconds. That time is reverse DNS, one + lookup per remote address — not the socket table. `netstat -ano` returns in + 33 ms on the same machine, and `Get-NetTCPConnection` takes 1.7 s through the + CIM layer. `netstat2` makes the `-ano` call (`GetExtendedTcpTable` / + `GetExtendedUdpTable`) directly: `SELECT COUNT(*) FROM sockets()` over 204 + sockets reports ~6 ms. On Linux it is netlink `sock_diag` for the sockets and + a walk of `/proc/*/fd` to map socket inodes to pids. If names are ever wanted + they belong in an explicit function, never in this generator. +- **State is normalised** to `LISTEN`, `SYN_SENT`, `SYN_RECEIVED`, + `ESTABLISHED`, `FIN_WAIT_1`, `FIN_WAIT_2`, `CLOSE_WAIT`, `CLOSING`, + `LAST_ACK`, `TIME_WAIT`, `CLOSED`, `UNKNOWN` — our spellings rather than + `netstat2`'s `Display`, which says `SYN_RCVD` and `__UNKNOWN`. Windows' + `DELETE_TCB` (a control block being torn down) has no RFC 793 equivalent and + maps to `CLOSED`. +- **Row-shape decisions, as settled 2026-09-13:** + - **One row per *(socket, pid)*.** Linux can report several owners for one + socket (a listener shared across a fork); Windows reports at most one. + Duplicate pids are collapsed and the rows come out in pid order. + - **A socket with no visible owner still gets exactly one row**, with a NULL + `pid`. Principle 2 applied to rows. + - **UDP has no far end and no state**, so `remote_address`, `remote_port` and + `state` are NULL rather than `0.0.0.0` / `0` / an invented state. +- **Extended during the build: a TCP listener has no far end either.** The OS + reports a listener's remote end as `0.0.0.0:0` (or `[::]:0`). That is the + same absence as UDP's, so it gets the same NULLs — otherwise + `GROUP BY remote_address` would count every listener under a fake peer. The + rule is an unspecified address *and* port 0, whatever the state. +- **Pid 0 is not an owner — the S6 trap, met here first.** Windows reports + pid 0 for sockets no process holds any more; on this machine all 7 + `TIME_WAIT` rows had it. `processes()` lists pid 0 as `Idle`, so passing it + through would have made the join claim the idle process held those + connections — a plausible answer, and wrong. Pid 0 becomes NULL. Pid 4 + (`System`) is different: it really does own the kernel's listeners on + 139 and 445, and the join names it correctly. +- **Expect partial answers without privileges.** On Linux the pid of another + user's socket needs root to read from `/proc//fd`; the socket still + appears, with a NULL `pid`. On Windows every socket still owned by a process + had its pid without elevation — `user` in the joined `processes()` is where + Windows is reticent (S1). +- **Joins to S1 on `pid`** through the CTE form above — the first real + demonstration that these sources compose: + ```sql + WITH s AS (SELECT * FROM sockets() WHERE state = 'LISTEN'), + p AS (SELECT pid, name, user FROM processes()) + SELECT s.local_port, s.local_address, p.name, p.user + FROM s LEFT JOIN p ON s.pid = p.pid + ORDER BY s.local_port; + ``` + The join takes ~260 ms, nearly all of it S1's CPU sample. `sockets()` is the + cheap half. +- **Rows are sorted** TCP before UDP, then by local port, local address and far + end, so the same machine gives the same result twice. +- **The crate, checked before adding it:** `netstat2` 0.11.2 is maintained and + its dependencies are small. It lists `bindgen` as a build dependency on Linux + and macOS but only *runs* it on macOS (for `libproc`), so the Linux build does + not need libclang. `listeners` 0.6 was the alternative and was rejected: it + returns only the local end and drops any socket whose owner it cannot see, + which breaks the NULL rule. +- **Build without it:** `--no-default-features --features redis-cache` + compiles with `system-tables` off. Full `--no-default-features` currently + fails on an unrelated `RedisCache` import in `src/main_handlers.rs`; + that is not this feature's doing, but it means principle 4 is not being + exercised by that exact command. +- **Tested on Windows 11**, 204 sockets. Tests open their own sockets and look + for them — a listener, a connection to it and a UDP socket, all owned by the + test process — then check the fixed columns, the normalised states, the UDP + and listener NULLs, and that no row carries pid 0. `owners` is unit-tested + for the empty, pid-0 and duplicate cases. +- **Tested on Linux** (Ubuntu 22.04 under WSL2, built without libclang + installed): all four `system` tests pass, `threads_are_not_listed_as_processes` + included. 20 sockets, counted in ~3 ms. Checked against `ss -tulpn`: + - **As a normal user** every socket appears and none has a `pid` — none of + them belong to that user. The rows are there with NULLs, which is the rule + working. + - **As root** the join names `cupsd` (pid 690) on 631, exactly as + `ss -tulpn` does, and the others stay NULL — `ss` cannot name them either. + Under WSL2 the distributions share one network namespace but not a pid + namespace, so sockets opened by *another* running distribution (6379, + 1433 here) are visible with no process this `/proc` can see. Expect the same + inside containers that share the host network. The NULL is the honest + answer there, not a bug to chase. ## S3 — Walking the process tree - **Status:** 🔴 OPEN diff --git a/examples/system_sockets.sql b/examples/system_sockets.sql new file mode 100644 index 00000000..fec4dd33 --- /dev/null +++ b/examples/system_sockets.sql @@ -0,0 +1,111 @@ +-- Querying the running machine's sockets (S2, docs/SYSTEM_TABLES.md). +-- +-- sockets() reads the OS's own TCP and UDP tables, the same ones `netstat -ano` +-- reads, with the owning pid. Columns are the same on Windows, Linux and macOS: +-- protocol, family, local_address, local_port, remote_address, remote_port, +-- state, pid. +-- +-- Things to know before reading any result: +-- * nothing is resolved to a hostname. Reverse DNS is why `netstat -a` takes +-- seconds; the table itself comes back in milliseconds; +-- * one row per (socket, pid). A socket whose owner is not visible - another +-- user's on Linux without root, or a TIME_WAIT left by an exited process on +-- Windows - still appears, with a NULL pid; +-- * UDP and listening TCP sockets have no far end, so remote_address and +-- remote_port are NULL, not 0.0.0.0 and 0. UDP has no state either; +-- * state uses the RFC 793 names: LISTEN, ESTABLISHED, TIME_WAIT, ...; +-- * joining to processes() goes through CTEs (P45), and processes() costs +-- ~250ms for its CPU sample - sockets() itself is the cheap half. + +-- --------------------------------------------------------------------------- +-- The shape of the machine's networking at a glance. +-- --------------------------------------------------------------------------- +SELECT protocol, state, COUNT(*) AS sockets, COUNT(pid) AS with_owner +FROM sockets() +GROUP BY protocol, state +ORDER BY sockets DESC; +GO + +-- --------------------------------------------------------------------------- +-- Which process is listening on which port - the question this table exists +-- to answer. A LEFT JOIN, so a listener with no visible owner is still listed. +-- --------------------------------------------------------------------------- +WITH s AS (SELECT * FROM sockets() WHERE state = 'LISTEN'), + p AS (SELECT pid, name, user FROM processes()) +SELECT + s.local_port, + s.local_address, + s.family, + s.pid, + p.name, + p.user +FROM s +LEFT JOIN p ON s.pid = p.pid +ORDER BY s.local_port, s.family; +GO + +-- --------------------------------------------------------------------------- +-- Listening only on this machine, or on every interface? 0.0.0.0 and :: accept +-- connections from anywhere; 127.0.0.1 and ::1 only from here. +-- --------------------------------------------------------------------------- +SELECT + CASE + WHEN local_address IN ('0.0.0.0', '::') THEN 'all interfaces' + WHEN local_address IN ('127.0.0.1', '::1') THEN 'loopback only' + ELSE 'one interface' + END AS exposure, + COUNT(*) AS listeners +FROM sockets() +WHERE state = 'LISTEN' +GROUP BY exposure +ORDER BY listeners DESC; +GO + +-- --------------------------------------------------------------------------- +-- Which processes hold the most open connections. +-- --------------------------------------------------------------------------- +WITH s AS (SELECT pid FROM sockets() WHERE state = 'ESTABLISHED'), + p AS (SELECT pid, name FROM processes()) +SELECT p.name, COUNT(*) AS connections +FROM s +JOIN p ON s.pid = p.pid +GROUP BY p.name +ORDER BY connections DESC +LIMIT 10; +GO + +-- --------------------------------------------------------------------------- +-- Where outbound connections go, by remote port - 443 is HTTPS, 22 is SSH. +-- Loopback is excluded, since both ends of those are this machine. +-- --------------------------------------------------------------------------- +SELECT remote_port, COUNT(*) AS connections +FROM sockets() +WHERE state = 'ESTABLISHED' + AND remote_address NOT IN ('127.0.0.1', '::1') +GROUP BY remote_port +ORDER BY connections DESC +LIMIT 10; +GO + +-- --------------------------------------------------------------------------- +-- Connections still winding down. TIME_WAIT on Windows usually has no owner: +-- the process has gone and the OS is holding the port for a while. +-- CLOSE_WAIT that lingers means a program never closed its end. +-- --------------------------------------------------------------------------- +SELECT state, local_port, remote_address, remote_port, pid +FROM sockets() +WHERE state IN ('TIME_WAIT', 'CLOSE_WAIT', 'FIN_WAIT_1', 'FIN_WAIT_2', 'LAST_ACK') +ORDER BY state, local_port; +GO + +-- --------------------------------------------------------------------------- +-- UDP sockets and their owners. No state and no far end - just a bound port. +-- --------------------------------------------------------------------------- +WITH s AS (SELECT local_address, local_port, pid FROM sockets() WHERE protocol = 'udp'), + p AS (SELECT pid, name FROM processes()) +SELECT s.local_port, s.local_address, p.name +FROM s +LEFT JOIN p ON s.pid = p.pid +ORDER BY s.local_port +LIMIT 15; +GO diff --git a/src/sql/generators/mod.rs b/src/sql/generators/mod.rs index 41dbcf64..0cd7572f 100644 --- a/src/sql/generators/mod.rs +++ b/src/sql/generators/mod.rs @@ -107,6 +107,8 @@ impl GeneratorRegistry { // System tables #[cfg(feature = "system-tables")] self.register(Box::new(system::Processes)); + #[cfg(feature = "system-tables")] + self.register(Box::new(system::Sockets)); } pub fn register(&mut self, generator: Box) { diff --git a/src/sql/generators/system.rs b/src/sql/generators/system.rs index 0d9b75b4..1100e4c2 100644 --- a/src/sql/generators/system.rs +++ b/src/sql/generators/system.rs @@ -1,6 +1,7 @@ -//! System tables: the running machine as something to query (S1). +//! System tables: the running machine as something to query (S1, S2). //! -//! `SELECT * FROM processes() WHERE name LIKE 'node%'`. +//! `SELECT * FROM processes() WHERE name LIKE 'node%'`, +//! `SELECT * FROM sockets() WHERE state = 'LISTEN'`. //! //! These are ordinary [`TableGenerator`]s, the same shape as the file readers //! next door, so nothing in the parser or the executor knows they are special. @@ -21,6 +22,10 @@ use crate::data::datatable::{DataColumn, DataRow, DataTable, DataType, DataValue}; use crate::sql::generators::TableGenerator; use anyhow::Result; +use netstat2::{ + get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo, SocketInfo, TcpState, +}; +use std::net::IpAddr; use std::sync::Arc; use sysinfo::{ Pid, ProcessRefreshKind, ProcessStatus, ProcessesToUpdate, System, Users, @@ -187,6 +192,175 @@ impl TableGenerator for Processes { } } +/// One row per socket per owning process (S2). +/// +/// This reads the OS's own socket tables - `GetExtendedTcpTable` and +/// `GetExtendedUdpTable` on Windows, netlink `sock_diag` on Linux, `libproc` on +/// macOS - through `netstat2`. **Nothing is resolved to a name.** Reverse DNS +/// is what makes `netstat -a` take seconds; the tables themselves come back in a +/// few milliseconds, and addresses stay as the literal IPs. +pub struct Sockets; + +impl Sockets { + /// RFC 793 spellings, so `WHERE state = 'LISTEN'` means the same thing on + /// every platform. Ours rather than `netstat2`'s `Display`, which says + /// `SYN_RCVD` and `__UNKNOWN`. + fn state_name(state: TcpState) -> &'static str { + match state { + TcpState::Listen => "LISTEN", + TcpState::SynSent => "SYN_SENT", + TcpState::SynReceived => "SYN_RECEIVED", + TcpState::Established => "ESTABLISHED", + TcpState::FinWait1 => "FIN_WAIT_1", + TcpState::FinWait2 => "FIN_WAIT_2", + TcpState::CloseWait => "CLOSE_WAIT", + TcpState::Closing => "CLOSING", + TcpState::LastAck => "LAST_ACK", + TcpState::TimeWait => "TIME_WAIT", + // DELETE_TCB is Windows-only: the connection's control block is + // being torn down. RFC 793 has no such state; the nearest is the + // one it is about to reach. + TcpState::Closed | TcpState::DeleteTcb => "CLOSED", + TcpState::Unknown => "UNKNOWN", + } + } + + fn family(address: &IpAddr) -> &'static str { + match address { + IpAddr::V4(_) => "ipv4", + IpAddr::V6(_) => "ipv6", + } + } + + /// The `pid` cells for one socket: one per distinct owner, or a single + /// NULL when no owner is visible - the socket is real either way, so it + /// always gets a row. + /// + /// Pid 0 is not an owner. Windows reports it for sockets no process holds + /// any more (a `TIME_WAIT` whose process has exited), and pid 0 is also + /// the System Idle Process in `processes()` - passing it through would make + /// the join claim the idle process holds those connections. + fn owners(pids: &[u32]) -> Vec { + let mut owners: Vec = pids.iter().copied().filter(|&pid| pid != 0).collect(); + owners.sort_unstable(); + owners.dedup(); + if owners.is_empty() { + return vec![DataValue::Null]; + } + owners + .into_iter() + .map(|pid| DataValue::Integer(i64::from(pid))) + .collect() + } + + /// Protocol, family, local address and port, remote address and port, and + /// state - everything but the pid. + fn socket_cells(socket: &SocketInfo) -> Vec { + let local_address = socket.local_addr(); + let (protocol, remote, state) = match &socket.protocol_socket_info { + ProtocolSocketInfo::Tcp(tcp) => { + // A listener - or a socket not yet connected - has no far end, + // which the OS reports as 0.0.0.0:0 or [::]:0. That is the same + // absence as UDP's, and it gets the same NULLs, not an address + // that would group and filter as if it were one. + let remote = if tcp.remote_addr.is_unspecified() && tcp.remote_port == 0 { + None + } else { + Some((tcp.remote_addr, tcp.remote_port)) + }; + ("tcp", remote, Some(Self::state_name(tcp.state))) + } + // UDP is connectionless: no remote end and no state. + ProtocolSocketInfo::Udp(_) => ("udp", None, None), + }; + + vec![ + DataValue::String(protocol.to_string()), + DataValue::String(Self::family(&local_address).to_string()), + DataValue::String(local_address.to_string()), + DataValue::Integer(i64::from(socket.local_port())), + remote.map_or(DataValue::Null, |(address, _)| { + DataValue::String(address.to_string()) + }), + remote.map_or(DataValue::Null, |(_, port)| { + DataValue::Integer(i64::from(port)) + }), + state.map_or(DataValue::Null, |name| DataValue::String(name.to_string())), + ] + } + + /// A stable order, so the same machine gives the same result twice: TCP + /// before UDP, then by local port and address, then by the far end. + fn sort_key(socket: &SocketInfo) -> (u8, u16, IpAddr, Option<(IpAddr, u16)>) { + match &socket.protocol_socket_info { + ProtocolSocketInfo::Tcp(tcp) => ( + 0, + tcp.local_port, + tcp.local_addr, + Some((tcp.remote_addr, tcp.remote_port)), + ), + ProtocolSocketInfo::Udp(udp) => (1, udp.local_port, udp.local_addr, None), + } + } +} + +impl TableGenerator for Sockets { + fn name(&self) -> &str { + "SOCKETS" + } + + fn description(&self) -> &str { + "Every TCP and UDP socket: protocol, local and remote address and port, TCP state, owning pid (joins to processes() on pid)" + } + + fn arg_count(&self) -> usize { + 0 + } + + fn columns(&self) -> Vec { + vec![ + DataColumn::new("protocol").with_type(DataType::String), + DataColumn::new("family").with_type(DataType::String), + DataColumn::new("local_address").with_type(DataType::String), + DataColumn::new("local_port").with_type(DataType::Integer), + DataColumn::new("remote_address").with_type(DataType::String), + DataColumn::new("remote_port").with_type(DataType::Integer), + DataColumn::new("state").with_type(DataType::String), + DataColumn::new("pid").with_type(DataType::Integer), + ] + } + + fn generate(&self, _args: Vec) -> Result> { + let mut sockets = get_sockets_info( + AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6, + ProtocolFlags::TCP | ProtocolFlags::UDP, + ) + .map_err(|e| anyhow::anyhow!("sockets(): {e}"))?; + sockets.sort_by_key(Self::sort_key); + + let mut table = DataTable::new("sockets"); + for column in self.columns() { + table.add_column(column); + } + + for socket in &sockets { + let cells = Self::socket_cells(socket); + // One row per (socket, pid), so `JOIN ... ON pid` stays a plain + // equi-join. On Linux a listener shared across a fork has several + // owners; on Windows there is always at most one. + for pid in Self::owners(&socket.associated_pids) { + let mut row = cells.clone(); + row.push(pid); + table + .add_row(DataRow::new(row)) + .map_err(|e| anyhow::anyhow!("sockets(): {e}"))?; + } + } + + Ok(Arc::new(table)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -286,4 +460,130 @@ mod tests { } } } + + #[test] + fn a_socket_without_a_visible_owner_still_gets_one_row() { + assert_eq!(Sockets::owners(&[]), vec![DataValue::Null]); + // Windows' "no process holds this any more", not the idle process. + assert_eq!(Sockets::owners(&[0]), vec![DataValue::Null]); + assert_eq!( + Sockets::owners(&[42, 7, 42, 0]), + vec![DataValue::Integer(7), DataValue::Integer(42)], + "one row per distinct owner, in pid order" + ); + } + + /// Like `processes()`, one test that does everything, and the surest facts + /// available are the sockets this test opens itself: a listener, a + /// connection to it, and a UDP socket, all owned by this process. + #[test] + fn sockets_describes_the_running_machine() { + use std::net::{TcpListener, TcpStream, UdpSocket}; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind a listener"); + let listen_port = i64::from(listener.local_addr().unwrap().port()); + let client = TcpStream::connect(listener.local_addr().unwrap()).expect("connect"); + let client_port = i64::from(client.local_addr().unwrap().port()); + let (_server_side, _) = listener.accept().expect("accept"); + let udp = UdpSocket::bind("127.0.0.1:0").expect("bind udp"); + let udp_port = i64::from(udp.local_addr().unwrap().port()); + + let table = Sockets.generate(Vec::new()).expect("read sockets"); + assert_eq!( + table.column_names(), + vec![ + "protocol", + "family", + "local_address", + "local_port", + "remote_address", + "remote_port", + "state", + "pid", + ], + "the column set is fixed, and identical on every platform" + ); + + let me = DataValue::Integer(std::process::id() as i64); + let text = |s: &str| DataValue::String(s.to_string()); + let find = |protocol: &str, port: i64, state: Option<&str>| { + (0..table.row_count()) + .find(|&row| { + table.get_value(row, 0) == Some(&text(protocol)) + && table.get_value(row, 3) == Some(&DataValue::Integer(port)) + && table.get_value(row, 6) == Some(&state.map_or(DataValue::Null, text)) + }) + .unwrap_or_else(|| panic!("no {protocol} socket on port {port} in state {state:?}")) + }; + + let listening = find("tcp", listen_port, Some("LISTEN")); + assert_eq!(table.get_value(listening, 1), Some(&text("ipv4"))); + assert_eq!(table.get_value(listening, 2), Some(&text("127.0.0.1"))); + assert_eq!( + table.get_value(listening, 4), + Some(&DataValue::Null), + "a listener has no remote end, so NULL rather than 0.0.0.0" + ); + assert_eq!(table.get_value(listening, 5), Some(&DataValue::Null)); + assert_eq!(table.get_value(listening, 7), Some(&me), "we own it"); + + let connected = find("tcp", client_port, Some("ESTABLISHED")); + assert_eq!(table.get_value(connected, 4), Some(&text("127.0.0.1"))); + assert_eq!( + table.get_value(connected, 5), + Some(&DataValue::Integer(listen_port)) + ); + assert_eq!(table.get_value(connected, 7), Some(&me)); + + let datagram = find("udp", udp_port, None); + assert_eq!(table.get_value(datagram, 4), Some(&DataValue::Null)); + assert_eq!(table.get_value(datagram, 5), Some(&DataValue::Null)); + assert_eq!(table.get_value(datagram, 7), Some(&me)); + + // Every row, including sockets owned by other users. + for row in 0..table.row_count() { + let protocol = table.get_value(row, 0); + assert!( + protocol == Some(&text("tcp")) || protocol == Some(&text("udp")), + "row {row} has protocol {protocol:?}" + ); + let family = table.get_value(row, 1); + assert!( + family == Some(&text("ipv4")) || family == Some(&text("ipv6")), + "row {row} has family {family:?}" + ); + assert!(matches!( + table.get_value(row, 3), + Some(DataValue::Integer(_)) + )); + if protocol == Some(&text("udp")) { + for column in [4, 5, 6] { + assert_eq!( + table.get_value(row, column), + Some(&DataValue::Null), + "row {row}: UDP has no remote end and no state" + ); + } + } else { + assert!( + matches!( + table.get_value(row, 6), + Some(DataValue::String(s)) if matches!( + s.as_str(), + "LISTEN" | "SYN_SENT" | "SYN_RECEIVED" | "ESTABLISHED" + | "FIN_WAIT_1" | "FIN_WAIT_2" | "CLOSE_WAIT" | "CLOSING" + | "LAST_ACK" | "TIME_WAIT" | "CLOSED" | "UNKNOWN" + ) + ), + "row {row} has a state outside the normalised set: {:?}", + table.get_value(row, 6) + ); + } + assert_ne!( + table.get_value(row, 7), + Some(&DataValue::Integer(0)), + "row {row}: pid 0 is not an owner" + ); + } + } }