From d3d11a7540d10d9c3deb465f133aba55b7d410c8 Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Thu, 17 Sep 2026 08:08:39 +0100 Subject: [PATCH] feat(system): which process holds that port, with sockets() (S2) SELECT * FROM sockets() WHERE state = 'LISTEN', joined to processes() on pid through CTEs. An ordinary TableGenerator beside processes(), reading the OS's own socket tables through netstat2: GetExtendedTcpTable and GetExtendedUdpTable on Windows, netlink sock_diag plus /proc/*/fd on Linux, libproc on macOS. Nothing is resolved to a hostname. Reverse DNS is what makes `netstat -a` take seconds on Windows; the table itself comes back in ~6 ms over 204 sockets here, and ~3 ms on Linux. Eight columns, identical on every platform: protocol, family, local_address, local_port, remote_address, remote_port, state, pid. One row per (socket, pid); a socket with no visible owner still gets one row with a NULL pid. State uses the RFC 793 names, with Windows' DELETE_TCB mapped to CLOSED. Two absences come out as NULL rather than plausible values: - UDP and listening TCP sockets have no far end, which the OS reports as 0.0.0.0:0. Those are NULL remote columns. - Windows gives pid 0 to sockets whose process has exited (every TIME_WAIT row here), and processes() lists pid 0 as Idle. Passing it through would have made the join blame the idle process. Tested on Windows 11 and on Ubuntu under WSL2, where the build needs no libclang (netstat2 only runs bindgen on macOS). As root on Linux the join agrees with `ss -tulpn`; sockets owned by other WSL distributions sharing the network namespace stay NULL there, as they do for ss. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.toml | 8 +- docs/SYSTEM_TABLES.md | 129 +++++++++++---- examples/system_sockets.sql | 111 +++++++++++++ src/sql/generators/mod.rs | 2 + src/sql/generators/system.rs | 304 ++++++++++++++++++++++++++++++++++- 5 files changed, 523 insertions(+), 31 deletions(-) create mode 100644 examples/system_sockets.sql 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" + ); + } + } }