Files
odboy 5d4631c053 fix: net ports 表格按显示宽度对齐,修正中文表头导致的错位
text/tabwriter 按字节数计算列宽,中文表头每个字 3 字节但只占 2 列,
导致表头下方的数据列整体错位。改为自行计算终端显示宽度(CJK 算 2 列)
补空格,行尾不留空白;监听地址 0.0.0.0/:: 统一显示为 *。
2026-11-01 08:39:37 +08:00

293 lines
9.7 KiB
Go

package executor
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// linuxSS is `ss -tulnp` output in the shape iproute2 prints it.
const linuxSS = `Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:* users:(("dhclient",pid=812,fd=6))
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=901,fd=3))
tcp LISTEN 0 511 [::]:80 [::]:* users:(("nginx",pid=1200,fd=6))
tcp LISTEN 0 4096 127.0.0.1:6379 0.0.0.0:* users:(("redis-server",pid=1500,fd=6))
tcp ESTAB 0 0 10.0.0.5:54321 10.0.0.9:443
`
// macOSLsof is `lsof -iTCP -sTCP:LISTEN -nP` output.
const macOSLsof = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
ControlCe 446 mac 8u IPv4 0xc6e1f3949f65dbaf 0t0 TCP *:7000 (LISTEN)
ControlCe 446 mac 9u IPv6 0xc1e379d06e632fc8 0t0 TCP *:7000 (LISTEN)
node 6200 mac 43u IPv6 0x1228305ce20b957 0t0 TCP [::1]:5173 (LISTEN)
`
// windowsNetstat is `netstat -ano` output.
const windowsNetstat = `Active Connections
Proto Local Address Foreign Address State PID
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234
TCP 127.0.0.1:5432 0.0.0.0:0 LISTENING 5678
TCP 10.0.0.5:49234 10.0.0.9:443 ESTABLISHED 5678
UDP 0.0.0.0:53 *:* 4321
`
func TestParseSSListeners(t *testing.T) {
got := parseSSListeners([]byte(linuxSS))
if len(got) != 4 {
t.Fatalf("parsed %d listeners, want 4 (the ESTAB row is not listening):\n%+v", len(got), got)
}
// Sorted by port.
wantPorts := []int{22, 68, 80, 6379}
for i, want := range wantPorts {
if got[i].Port != want {
t.Fatalf("row %d port = %d, want %d (%+v)", i, got[i].Port, want, got)
}
}
byPort := map[int]listeningPort{}
for _, p := range got {
byPort[p.Port] = p
}
if p := byPort[22]; p.Proto != "TCP" || p.Process != "sshd" || p.PID != "901" || p.Address != "0.0.0.0" {
t.Errorf("port 22 = %+v", p)
}
if p := byPort[80]; p.Address != "::" {
t.Errorf("IPv6 bracket not stripped: %+v", p)
}
if p := byPort[68]; p.Proto != "UDP" || p.Process != "dhclient" {
t.Errorf("udp row = %+v", p)
}
}
func TestParseLsofListeners(t *testing.T) {
got := parseLsofListeners([]byte(macOSLsof))
// The two ControlCe rows are the same socket in two families and collapse.
if len(got) != 2 {
t.Fatalf("parsed %d listeners, want 2:\n%+v", len(got), got)
}
if got[0].Port != 5173 || got[0].Address != "::1" || got[0].Process != "node" || got[0].PID != "6200" {
t.Errorf("first row = %+v", got[0])
}
if got[1].Port != 7000 || got[1].Process != "ControlCe" || got[1].PID != "446" {
t.Errorf("second row = %+v", got[1])
}
}
func TestParseNetstatListeners(t *testing.T) {
got := parseNetstatListeners([]byte(windowsNetstat))
// ESTABLISHED TCP rows are dropped; the bound UDP socket is kept.
if len(got) != 3 {
t.Fatalf("parsed %d listeners, want 3:\n%+v", len(got), got)
}
if got[0].Port != 53 || got[0].Proto != "UDP" || got[0].PID != "4321" {
t.Errorf("udp row = %+v", got[0])
}
if got[1].Port != 135 || got[1].PID != "1234" {
t.Errorf("port 135 = %+v", got[1])
}
if got[2].Port != 5432 || got[2].Address != "127.0.0.1" {
t.Errorf("port 5432 = %+v", got[2])
}
}
func TestListenCommandFor(t *testing.T) {
cases := map[string][]string{
"linux": {"ss", "-tulnp"},
"darwin": {"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"},
"windows": {"netstat", "-ano"},
}
for goos, want := range cases {
spec := listenCommandFor(goos)
got := append([]string{spec.program}, spec.args...)
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("%s: command = %v, want %v", goos, got, want)
}
if spec.parse == nil {
t.Errorf("%s: no parser", goos)
}
}
}
func TestSplitHostPort(t *testing.T) {
cases := []struct {
in string
addr string
port int
ok bool
}{
{"0.0.0.0:22", "0.0.0.0", 22, true},
{"*:7000", "*", 7000, true},
{"[::]:80", "::", 80, true},
{"[::1]:5173", "::1", 5173, true},
{"::1:5432", "::1", 5432, true},
{"nope", "", 0, false},
{"host:99999", "", 0, false},
}
for _, tc := range cases {
addr, port, ok := splitHostPort(tc.in)
if ok != tc.ok || addr != tc.addr || port != tc.port {
t.Errorf("splitHostPort(%q) = (%q, %d, %v), want (%q, %d, %v)",
tc.in, addr, port, ok, tc.addr, tc.port, tc.ok)
}
}
}
func TestRenderListeningPortsTable(t *testing.T) {
ports := dedupeListeners(parseSSListeners([]byte(linuxSS)))
var out bytes.Buffer
renderListeningPorts(ports, BuiltinRequest{Stdout: &out, Stderr: &out})
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
header := lines[0]
for _, col := range []string{"端口", "协议", "地址", "PID", "进程"} {
if !strings.Contains(header, col) {
t.Errorf("header %q missing column %q", header, col)
}
}
if len(lines) != 5 {
t.Fatalf("expected header + 4 rows, got %d lines:\n%s", len(lines), out.String())
}
// Rows are ordered by port, with the owner columns filled in.
if !strings.HasPrefix(strings.TrimSpace(lines[1]), "22") || !strings.Contains(lines[1], "sshd") {
t.Errorf("first row = %q", lines[1])
}
if !strings.Contains(out.String(), "redis-server") {
t.Errorf("process column missing redis-server:\n%s", out.String())
}
}
func TestRenderListeningPortsEmpty(t *testing.T) {
var out, errOut bytes.Buffer
renderListeningPorts(nil, BuiltinRequest{Stdout: &out, Stderr: &errOut})
if out.Len() != 0 {
t.Errorf("stdout = %q, want empty", out.String())
}
if !strings.Contains(errOut.String(), "没有正在监听的端口") {
t.Errorf("stderr = %q, want a no-listener note", errOut.String())
}
}
func TestRenderListeningPortsUnknownOwner(t *testing.T) {
ports := []listeningPort{{Proto: "UDP", Address: "0.0.0.0", Port: 53}}
var out bytes.Buffer
renderListeningPorts(ports, BuiltinRequest{Stdout: &out, Stderr: &out})
if !strings.Contains(out.String(), "53") || strings.Count(out.String(), "-") < 2 {
t.Errorf("unknown owner should print placeholders:\n%s", out.String())
}
}
func TestNetPortsRejectsUnknownOption(t *testing.T) {
err := runNetPorts(context.Background(), BuiltinRequest{Args: []string{"--nope"}, Stdout: &bytes.Buffer{}})
if err == nil || !strings.Contains(err.Error(), "未知选项") {
t.Fatalf("err = %v, want an unknown-option error", err)
}
}
// TestNetPortsRawKeepsCommandOutput feeds a fake `ss` and checks that --raw
// prints exactly what the platform command printed.
func TestNetPortsRawKeepsCommandOutput(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "ss")
payload := "tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=901,fd=3))\n"
body := "#!/bin/sh\nprintf '%s' " + strconvQuote(payload) + "\n"
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
// darwin resolves to lsof, so the fake answers under that name too.
if err := os.WriteFile(filepath.Join(dir, "lsof"), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
var out bytes.Buffer
if err := runNetPorts(context.Background(), BuiltinRequest{Args: []string{"--raw"}, Stdout: &out, Stderr: &out}); err != nil {
t.Fatalf("runNetPorts --raw: %v", err)
}
if strings.Contains(out.String(), "端口") {
t.Errorf("--raw must not render the table:\n%s", out.String())
}
}
// strconvQuote single-quotes a shell payload, escaping embedded quotes.
func strconvQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func TestDisplayWidth(t *testing.T) {
cases := map[string]int{
"端口": 4, // each CJK rune is two columns
"PID": 3,
"127.0.0.1": 9,
"进程": 4,
"": 0,
}
for in, want := range cases {
if got := displayWidth(in); got != want {
t.Errorf("displayWidth(%q) = %d, want %d", in, got, want)
}
}
}
// TestRenderListeningPortsAlignsCJKHeader guards the alignment bug: with the
// old byte-based tabwriter the CJK headings pushed every data column, because
// each heading rune counts as three bytes but only two columns.
func TestRenderListeningPortsAlignsCJKHeader(t *testing.T) {
ports := []listeningPort{
{Proto: "TCP", Address: "127.0.0.1", Port: 22, PID: "901", Process: "sshd"},
{Proto: "TCP", Address: "0.0.0.0", Port: 3306, PID: "2442", Process: "docker"},
}
var out bytes.Buffer
renderListeningPorts(ports, BuiltinRequest{Stdout: &out, Stderr: &out})
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("expected header + 2 rows, got %d:\n%s", len(lines), out.String())
}
// "协议" is the heading above the "TCP" column, so that cell must start at
// the same display column on every line — the bug put it further right.
want := columnStart(lines[0], "协议")
for i, line := range lines[1:] {
if got := columnStart(line, "TCP"); got != want {
t.Errorf("row %d: TCP starts at display column %d, header 协议 at %d:\n%s",
i+1, got, want, out.String())
}
}
for _, line := range lines {
if line != strings.TrimRight(line, " ") {
t.Errorf("trailing spaces in %q", line)
}
}
}
// columnStart returns the display column at which cell begins in line.
func columnStart(line, cell string) int {
i := strings.Index(line, cell)
if i < 0 {
return -1
}
return displayWidth(line[:i])
}
func TestDisplayAddress(t *testing.T) {
cases := map[string]string{
"0.0.0.0": "*",
"::": "*",
"": "*",
"127.0.0.1": "127.0.0.1",
"::1": "::1",
}
for in, want := range cases {
if got := displayAddress(in); got != want {
t.Errorf("displayAddress(%q) = %q, want %q", in, got, want)
}
}
}