From 4f4bfd87c48b7b2b8a6916e7519db7e840f30048 Mon Sep 17 00:00:00 2001 From: Erwann Masson Date: Fri, 17 Jul 2026 14:07:55 +0200 Subject: [PATCH 1/4] feat: add vmstat builtin for host memory/CPU/IO pressure investigation --- AGENTS.md | 2 + README.md | 2 +- SHELL_FEATURES.md | 1 + analysis/symbols_builtins.go | 18 + analysis/symbols_internal.go | 158 +++--- builtins/internal/vmstat/vmstat.go | 150 +++++ builtins/internal/vmstat/vmstat_darwin.go | 155 +++++ .../internal/vmstat/vmstat_darwin_test.go | 129 +++++ builtins/internal/vmstat/vmstat_linux.go | 313 ++++++++++ .../internal/vmstat/vmstat_linux_fuzz_test.go | 89 +++ .../vmstat/vmstat_linux_parse_test.go | 195 +++++++ builtins/internal/vmstat/vmstat_other.go | 16 + builtins/tests/vmstat/helpers_test.go | 57 ++ builtins/tests/vmstat/vmstat_linux_test.go | 227 ++++++++ builtins/vmstat/vmstat.go | 536 ++++++++++++++++++ interp/register_builtins.go | 2 + .../scenarios/cmd/vmstat/basic/sampling.yaml | 13 + .../scenarios/cmd/vmstat/basic/snapshot.yaml | 15 + tests/scenarios/cmd/vmstat/basic/stats.yaml | 14 + .../cmd/vmstat/errors/extra_operand.yaml | 10 + .../cmd/vmstat/errors/invalid_count.yaml | 10 + .../cmd/vmstat/errors/invalid_delay.yaml | 10 + .../cmd/vmstat/errors/invalid_unit.yaml | 10 + .../vmstat/errors/stats_extra_operand.yaml | 10 + .../cmd/vmstat/errors/unknown_flag.yaml | 9 + .../cmd/vmstat/flags/active_memory.yaml | 14 + .../cmd/vmstat/flags/rejected_flags.yaml | 22 + .../cmd/vmstat/flags/unit_scale.yaml | 13 + tests/scenarios/cmd/vmstat/flags/wide.yaml | 16 + .../scenarios/cmd/vmstat/help/help_long.yaml | 12 + .../scenarios/cmd/vmstat/help/help_short.yaml | 11 + 31 files changed, 2171 insertions(+), 68 deletions(-) create mode 100644 builtins/internal/vmstat/vmstat.go create mode 100644 builtins/internal/vmstat/vmstat_darwin.go create mode 100644 builtins/internal/vmstat/vmstat_darwin_test.go create mode 100644 builtins/internal/vmstat/vmstat_linux.go create mode 100644 builtins/internal/vmstat/vmstat_linux_fuzz_test.go create mode 100644 builtins/internal/vmstat/vmstat_linux_parse_test.go create mode 100644 builtins/internal/vmstat/vmstat_other.go create mode 100644 builtins/tests/vmstat/helpers_test.go create mode 100644 builtins/tests/vmstat/vmstat_linux_test.go create mode 100644 builtins/vmstat/vmstat.go create mode 100644 tests/scenarios/cmd/vmstat/basic/sampling.yaml create mode 100644 tests/scenarios/cmd/vmstat/basic/snapshot.yaml create mode 100644 tests/scenarios/cmd/vmstat/basic/stats.yaml create mode 100644 tests/scenarios/cmd/vmstat/errors/extra_operand.yaml create mode 100644 tests/scenarios/cmd/vmstat/errors/invalid_count.yaml create mode 100644 tests/scenarios/cmd/vmstat/errors/invalid_delay.yaml create mode 100644 tests/scenarios/cmd/vmstat/errors/invalid_unit.yaml create mode 100644 tests/scenarios/cmd/vmstat/errors/stats_extra_operand.yaml create mode 100644 tests/scenarios/cmd/vmstat/errors/unknown_flag.yaml create mode 100644 tests/scenarios/cmd/vmstat/flags/active_memory.yaml create mode 100644 tests/scenarios/cmd/vmstat/flags/rejected_flags.yaml create mode 100644 tests/scenarios/cmd/vmstat/flags/unit_scale.yaml create mode 100644 tests/scenarios/cmd/vmstat/flags/wide.yaml create mode 100644 tests/scenarios/cmd/vmstat/help/help_long.yaml create mode 100644 tests/scenarios/cmd/vmstat/help/help_short.yaml diff --git a/AGENTS.md b/AGENTS.md index 349aaf1a..5c82bdbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ The shell is supported on Linux, Windows and macOS. - **`df` bypasses `AllowedPaths` for mount-table enumeration.** `df` delegates filesystem listing to `builtins/internal/diskstats`, which on Linux reads `/proc/self/mountinfo` directly via `os.Open` and then calls `unix.Statfs(2)` on every mount point returned by the kernel. On macOS it calls `unix.Getfsstat(2)`. The mount-point paths are kernel-controlled — never derived from user input — so the same trade-off as `ss` / `ip route` applies: operators cannot use `AllowedPaths` to hide individual mounts from `df`. `Statfs` returns metadata only (block / inode counts, filesystem type, block size); no file content is read. +- **`vmstat` bypasses `AllowedPaths` for memory/CPU/IO pressure reads, and reports macOS-only-partial counters as `0`/`-` rather than fabricating them.** `vmstat` delegates counter collection to `builtins/internal/vmstat`, which on Linux reads `/proc/{stat,meminfo,vmstat,loadavg,uptime}` directly via `os.Open` (same documented exception as `ss`/`ip route`/`df`: the paths are hardcoded, never derived from user input) and on macOS calls `sysctl(3)` (`hw.memsize`, `vm.swapusage`, `vm.loadavg`) — the same darwin toolset `df` and `ss` already use, deliberately not Mach `host_statistics64` (no existing builtin uses Mach, and adding it would introduce a new syscall review surface). Consequently, on macOS: per-page memory breakdown (buffers/cache/active/inactive beyond the sysctl-reported total), CPU tick counters, and paging/interrupt/context-switch rates are unavailable. Column groups with no data source render as `-`; `MemFree`/`MemBuffers`/`MemCached`/`MemActive`/`MemInactive` render as `0` because macOS has no sysctl exposing them without Mach — this is a documented, intentional limitation (see `builtins/internal/vmstat/vmstat_darwin.go`), not a fabricated measurement. + ## CRITICAL: Bug Fixes and Bash Compatibility - **ALWAYS prioritise fixing the shell implementation to match bash behaviour over changing tests to match the current (incorrect) shell output.** Never "fix" a failing test by updating its expected output to match broken shell behaviour — fix the shell instead. diff --git a/README.md b/README.md index 0a2c197b..b39339d0 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ The development CLI accepts the equivalent syntax through `--allowed-services my - **Special targets:** The literal target `/dev/null` is always short-circuited to a discarded sink without going through the sandbox. `<>` (read-write open) is blocked in all modes. - **Diagnostic messages:** Configured directories that cannot be opened and invalid system service names are skipped with a warning flushed once to the runner's stderr at construction time. Silence them or redirect them programmatically with `WarningsWriter(io.Writer)` or inspect them with `Runner.Warnings()`. -> **Note:** The `ss`, `ip route`, and `df` builtins bypass `AllowedPaths` for their kernel-state reads. `ss` and `ip route` open `/proc/net/*` paths directly; `df` reads `/proc/self/mountinfo` (Linux) or calls `getfsstat(2)` (macOS), then issues `unix.Statfs(2)` against every kernel-reported mount point. These paths are hardcoded — never derived from user input — and `Statfs` returns metadata only (block / inode counts, filesystem type, block size). There is no sandbox-escape risk, but operators cannot use `AllowedPaths` to block `ss` from enumerating local sockets, `ip route` from reading the routing table, or `df` from reporting mount-table capacity — these reads succeed regardless of the configured path policy. +> **Note:** The `ss`, `ip route`, `df`, and `vmstat` builtins bypass `AllowedPaths` for their kernel-state reads. `ss` and `ip route` open `/proc/net/*` paths directly; `df` reads `/proc/self/mountinfo` (Linux) or calls `getfsstat(2)` (macOS), then issues `unix.Statfs(2)` against every kernel-reported mount point; `vmstat` reads `/proc/{stat,meminfo,vmstat,loadavg,uptime}` (Linux) or calls `sysctl(3)` (macOS: `hw.memsize`, `vm.swapusage`, `vm.loadavg`). These paths are hardcoded — never derived from user input — and all of these calls return metadata/counters only (no directory listings, no file contents). There is no sandbox-escape risk, but operators cannot use `AllowedPaths` to block `ss` from enumerating local sockets, `ip route` from reading the routing table, `df` from reporting mount-table capacity, or `vmstat` from reporting memory/CPU/IO pressure counters — these reads succeed regardless of the configured path policy. **ProcPath** (Linux-only) overrides the proc filesystem root used by the `ps` builtin (default `/proc`). This is a privileged option set at runner construction time by trusted caller code — scripts cannot influence it. Access to the proc path is intentionally not subject to `AllowedPaths` restrictions. To avoid leaking secrets passed as CLI arguments, `ps` does not read `/proc//cmdline`; the `CMD` column reports only the process comm/executable name. diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 3833ae17..e76216f9 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -42,6 +42,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `true` — return exit code 0 - ✅ `uname [-asnrvm]` — print system information (Linux only; reads from `/proc/sys/kernel/`, respects `--proc-path`) - ✅ `uniq [OPTION]... [INPUT]` — report or omit repeated lines +- ✅ `vmstat [-a] [-w] [-S k|K|m|M] [-s] [delay [count]]` — report virtual memory, swap, IO, and CPU pressure statistics; no arguments prints a since-boot snapshot, `delay [count]` samples repeatedly, `-s`/`--stats` prints a full counter summary; reads `/proc/*` (Linux) or `sysctl(3)` (macOS) directly, bypassing `AllowedPaths` (same exception as `df`/`ss`/`ip route`); macOS lacks several counters (Mach-only), shown as `-`/`0` rather than fabricated; `-d`, `-p`, `-f`, `-m`, `-t`, `-n`, `-V` are not implemented; not supported on Windows - ✅ `wc [-l] [-w] [-c] [-m] [-L] [FILE]...` — count lines, words, bytes, characters, or max line length - ✅ `xargs [-0] [-a FILE] [-d DELIM] [-E EOF-STR] [-I REPLSTR] [-L N] [-n N] [-r] [-s N] [-t] [-x] [COMMAND [INITIAL-ARGS]...]` — build and execute commands from standard input; only invokes other registered builtins (subject to `CommandAllowed`), so the GTFOBins shell-escape `xargs … /bin/sh` is rejected; flags outside the supported set above (e.g. `-p` interactive, `-P` parallel, `-o`/`--open-tty`, `--show-limits`) are rejected as unknown - ❌ All other commands — return exit code 127 with `: not found` unless an ExecHandler is configured diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index b0fb36be..2f6260c7 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -410,6 +410,21 @@ var builtinPerCommandSymbols = map[string][]string{ "strconv.ParseInt", // 🟢 string-to-int conversion with base/bit-size; pure function, no I/O. "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. }, + "vmstat": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. + "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. + "math.Round", // 🟢 rounds a float64 to the nearest integer; used for CPU-tick percentage rounding; pure function, no I/O. + "strconv.ParseUint", // 🟢 string-to-unsigned-int conversion; used to validate delay/count operands; pure function, no I/O. + "strings.Join", // 🟢 concatenates a slice of strings with a separator; pure function, no I/O. + "strings.Repeat", // 🟢 builds dash separators for the group header; pure function, no I/O. + "time.Duration", // 🟢 duration type; pure integer alias, no I/O. + "time.NewTimer", // 🟢 creates a timer that fires once after a duration; used for the sampling-interval wait; pure in-process scheduling, no I/O. + "time.Second", // 🟢 constant representing one second; no side effects. + // Note: builtins/internal/vmstat symbols are exempt from this + // allowlist (internal packages are not checked by the + // builtinAllowedSymbols test). + }, "ss": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. "errors.Is", // 🟢 error comparison; used to distinguish syscall.ENOENT from unexpected errors. @@ -584,6 +599,7 @@ var builtinPerCommandCallContextFields = map[string][]string{ "ss": {}, "true": {}, "uname": {}, + "vmstat": {}, "cat": { "OpenFile", @@ -770,6 +786,7 @@ var builtinAllowedSymbols = []string{ "math.MaxUint64", // 🟢 integer constant; no side effects. "math.MinInt64", // 🟢 integer constant; no side effects. "math.NaN", // 🟢 returns IEEE 754 NaN value; pure function, no I/O. + "math.Round", // 🟢 rounds a float64 to the nearest integer; pure function, no I/O. "net.DefaultResolver", // 🔴 default system DNS resolver; used for context-aware address lookup; network I/O is the explicit purpose of the ping builtin. "net.FlagBroadcast", // 🟢 interface flag constant: broadcast capability; pure constant, no network connections. "net.IPAddr", // 🟢 resolved IP address struct (IP + Zone); pure data type, no I/O. @@ -846,6 +863,7 @@ var builtinAllowedSymbols = []string{ "time.Hour", // 🟢 constant representing one hour; no side effects. "time.Millisecond", // 🟢 constant representing one millisecond; no side effects. "time.Minute", // 🟢 constant representing one minute; no side effects. + "time.NewTimer", // 🟢 creates a timer that fires once after a duration; pure in-process scheduling, no I/O. "time.ParseDuration", // 🟢 parses Go duration strings (e.g. "1s"); pure function, no I/O. "time.Second", // 🟢 constant representing one second; no side effects. "time.Time", // 🟢 time value type; pure data, no side effects. diff --git a/analysis/symbols_internal.go b/analysis/symbols_internal.go index 11dd63e3..c0ac2abe 100644 --- a/analysis/symbols_internal.go +++ b/analysis/symbols_internal.go @@ -141,6 +141,24 @@ var internalPerPackageSymbols = map[string][]string{ "strings.Split", // 🟢 splits address:port fields on ":"; pure function, no I/O. "strings.ToUpper", // 🟢 normalises hex state field to uppercase for map lookup; pure function, no I/O. }, + "vmstat": { + "bufio.NewScanner", // 🟢 line-by-line reading of /proc/{stat,meminfo,vmstat,loadavg,uptime}; no write capability. + "context.Context", // 🟢 deadline/cancellation interface; no side effects. + "errors.New", // 🟢 creates a sentinel error (ErrNotSupported, and a stub-platform error path); pure function, no I/O. + "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. + "io.EOF", // 🟢 sentinel error value returned by bufio.Scanner at end of file; pure constant. + "os.Getpagesize", // 🟢 returns the host's memory page size; read-only, no I/O. + "os.Open", // 🟠 opens /proc/{stat,meminfo,vmstat,loadavg,uptime} read-only; needed to stream kernel pseudo-files. + "path/filepath.Join", // 🟢 joins procPath + file name (e.g. "stat"); pure function, no I/O. + "strconv.ParseFloat", // 🟢 parses load-average and uptime float fields; pure function, no I/O. + "strconv.ParseUint", // 🟢 parses /proc counter fields; pure function, no I/O. + "strings.Cut", // 🟢 splits a "Key: value" meminfo line at the first colon; pure function, no I/O. + "strings.Fields", // 🟢 splits whitespace-separated /proc lines; pure function, no I/O. + "strings.HasPrefix", // 🟢 matches /proc/stat line prefixes (cpu/intr/ctxt/procs_*); pure function, no I/O. + "golang.org/x/sys/unix.Getpagesize", // 🟢 (darwin) returns the host's memory page size; read-only, no I/O. + "golang.org/x/sys/unix.SysctlRaw", // 🟠 (darwin) reads raw sysctl byte replies (vm.swapusage, vm.loadavg); read-only, no exec or write capability. + "golang.org/x/sys/unix.SysctlUint64", // 🟠 (darwin) reads a uint64 sysctl value (hw.memsize); read-only, no exec or write capability. + }, "winnet": { "encoding/binary.BigEndian", // 🟢 reads big-endian IPv6 group values from DLL buffer; pure value, no I/O. "encoding/binary.LittleEndian", // 🟢 reads little-endian DWORD fields from DLL buffer; pure value, no I/O. @@ -181,73 +199,79 @@ var internalAllowedSymbols = []string{ "bufio.ErrTooLong", // 🟢 diskstats: sentinel error for scanner buffer overflow; pure constant. "bufio.NewScanner", // 🟢 procinfo/diskstats: line-by-line reading of /proc files; no write capability. "github.com/DataDog/rshell/builtins/internal/procpath.Default", // 🟢 procinfo/procnet: canonical /proc filesystem root path constant; pure constant, no I/O. - "bytes.NewReader", // 🟢 procinfo: wraps a byte slice as an in-memory io.Reader; no I/O side effects. - "context.Context", // 🟢 procinfo: deadline/cancellation interface; no side effects. - "encoding/binary.BigEndian", // 🟢 winnet: reads big-endian IPv6 group values from DLL buffer; pure value, no I/O. - "encoding/binary.LittleEndian", // 🟢 winnet: reads little-endian DWORD fields from DLL buffer; pure value, no I/O. - "errors.Is", // 🟢 procinfo: checks whether an error in a chain matches a target; pure function, no I/O. - "errors.New", // 🟢 creates a sentinel error; pure function, no I/O. - "github.com/spf13/pflag.ContinueOnError", // 🟢 flagparser: pflag parsing-mode constant; pure constant, no I/O. - "github.com/spf13/pflag.FlagSet", // 🟢 flagparser: pflag FlagSet type used to trial-parse argv prefixes; pure type, no I/O. - "github.com/spf13/pflag.NewFlagSet", // 🟢 flagparser: constructs a throw-away FlagSet for trial-parsing; pure constructor, no I/O. - "io.Discard", // 🟢 flagparser: silences trial.SetOutput so trial-parse failures don't leak to stderr; pure no-op writer. - "math/bits.OnesCount32", // 🟢 procnet: counts set bits in a uint32 (popcount for prefix length); pure function, no I/O. - "math/bits.ReverseBytes32", // 🟢 procnet: byte-swaps a uint32 to convert little-endian /proc mask to network byte order for CIDR validation; pure function, no I/O. - "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. - "os.ErrNotExist", // 🟢 procinfo: sentinel error value indicating a file or directory does not exist; read-only constant, no I/O. - "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. - "io.LimitReader", // 🟢 procsyskernel: wraps a reader with a byte cap; pure wrapper, no I/O by itself. - "io.ReadAll", // 🟠 procsyskernel: reads all data from a bounded reader; used with LimitReader for 4KiB cap. - "io.Reader", // 🟢 diskstats: interface type used to feed parseMountInfo from arbitrary readers; pure type, no I/O. - "os.Getpid", // 🟠 procinfo: returns the current process ID; read-only, no side effects. - "os.ModeCharDevice", // 🟢 procsyskernel: file mode constant for char device detection; pure constant. - "os.O_RDONLY", // 🟢 procsyskernel: read-only open flag; pure constant. - "os.Open", // 🟠 procinfo: opens a file read-only; needed to stream /proc/stat line-by-line. - "os.OpenFile", // 🟠 procsyskernel: opens kernel pseudo-files with O_NONBLOCK; bypasses AllowedPaths by design. - "os.ReadDir", // 🟠 procinfo: reads a directory listing; needed to enumerate /proc entries. - "os.ReadFile", // 🟠 procinfo: reads a whole file; needed to read /proc/[pid]/{stat,status}. - "os.Stat", // 🟠 procinfo: validates that the proc path exists before enumeration; read-only metadata, no write capability. - "path/filepath.Base", // 🟢 procsyskernel: returns the last element of a path; validates name is a plain basename. - "path/filepath.Clean", // 🟢 procnetroute/procnetsocket: normalises procPath before ".." safety check; pure function, no I/O. - "path/filepath.Join", // 🟢 procinfo: joins path elements to construct /proc//stat paths; pure function, no I/O. - "strconv.Atoi", // 🟢 string-to-int conversion; pure function, no I/O. - "strconv.Itoa", // 🟢 procinfo: int-to-string conversion for PID directory names; pure function, no I/O. - "strconv.ParseInt", // 🟢 procinfo: string to int64 with base/bit-size; pure function, no I/O. - "strconv.FormatUint", // 🟢 procnetsocket: uint-to-string conversion for port/inode formatting; pure function, no I/O. - "strconv.ParseUint", // 🟢 procnetroute/procnetsocket: parses hex/decimal route and socket fields; pure function, no I/O. - "strings.Builder", // 🟢 procnetsocket/diskstats: efficient string concatenation; pure in-memory buffer, no I/O. - "strings.Contains", // 🟢 procnetroute/diskstats: substring check; pure function, no I/O. - "strings.ContainsRune", // 🟢 diskstats: fast-path check for backslash before unescape; pure function, no I/O. - "strings.Cut", // 🟢 diskstats: splits a string at the first separator; pure function, no I/O. - "strings.Fields", // 🟢 procinfo/procnetroute/procnetsocket/diskstats: splits a string on whitespace; pure function, no I/O. - "strings.Join", // 🟢 procnetsocket: reconstructs space-containing Unix socket paths from Fields tokens; pure function, no I/O. - "strings.Split", // 🟢 procnetsocket: splits address:port fields on ":"; pure function, no I/O. - "strings.ToUpper", // 🟢 procnetsocket: normalises hex state field to uppercase for map lookup; pure function, no I/O. - "strings.CutPrefix", // 🟢 flagparser: trims known pflag error prefixes before rewriting; pure function, no I/O. - "strings.HasPrefix", // 🟢 procinfo/diskstats: checks string prefix; pure function, no I/O. - "strings.HasSuffix", // 🟢 flagparser: matches pflag error suffixes (e.g. "flag does not allow an argument"); pure function, no I/O. - "strings.Index", // 🟢 procinfo: finds first occurrence of a substring; pure function, no I/O. - "strings.LastIndex", // 🟢 procinfo: finds last occurrence of a substring; pure function, no I/O. - "strings.TrimRight", // 🟢 procinfo: trims trailing characters; pure function, no I/O. - "strings.TrimSpace", // 🟢 procinfo: removes leading/trailing whitespace; pure function, no I/O. - "syscall.Errno", // 🟢 winnet: wraps DLL return code as an error type; pure type, no I/O. - "syscall.Getsid", // 🟠 procinfo: returns the session ID of a process; read-only syscall, no write/exec. - "syscall.O_NONBLOCK", // 🟢 procsyskernel: non-blocking open flag to prevent FIFO hang; pure constant. - "syscall.MustLoadDLL", // 🔴 winnet: loads iphlpapi.dll once at program init; read-only OS loader call. - "syscall.Proc", // 🟢 winnet: DLL procedure handle type used in function signature; pure type, no I/O. - "time.Now", // 🟠 procinfo: returns the current wall-clock time; read-only, no side effects. - "time.Unix", // 🟢 procinfo: constructs a Time from Unix seconds; pure function, no I/O. - "unsafe.Pointer", // 🔴 winnet: passes buffer/size pointers to DLL via syscall ABI. No pointer arithmetic; buffer parsed with encoding/binary after the call. - "golang.org/x/sys/unix.ByteSliceToString", // 🟢 diskstats (darwin): converts a NUL-terminated kernel byte buffer to a Go string; pure function, no I/O. - "golang.org/x/sys/unix.Getfsstat", // 🟠 diskstats (darwin): read-only enumeration of mounted filesystems via getfsstat(2); no exec or write capability. - "golang.org/x/sys/unix.KinfoProc", // 🟢 procinfo (darwin): struct type carrying per-process kinfo_proc data from sysctl; read-only data, no exec capability. - "golang.org/x/sys/unix.MNT_LOCAL", // 🟢 diskstats (darwin): flag constant indicating a local-only filesystem; pure constant. - "golang.org/x/sys/unix.MNT_NOWAIT", // 🟢 diskstats (darwin): flag constant: do not block on remote FS for getfsstat; pure constant. - "golang.org/x/sys/unix.Statfs", // 🟠 diskstats (linux): read-only filesystem usage syscall; no exec or write capability. - "golang.org/x/sys/unix.Statfs_t", // 🟢 diskstats: struct type carrying filesystem usage data from statfs/getfsstat; pure data type. - "golang.org/x/sys/unix.SysctlKinfoProc", // 🟠 procinfo (darwin): reads a single process's kinfo_proc via kern.proc.pid sysctl; read-only, no exec or write capability. - "golang.org/x/sys/unix.SysctlKinfoProcSlice", // 🟠 procinfo (darwin): reads all processes' kinfo_proc via kern.proc.all sysctl; read-only, no exec or write capability. - "golang.org/x/sys/windows.CloseHandle", // 🟠 procinfo (windows): closes a process-snapshot handle after enumeration; no data read or exec capability. + "bytes.NewReader", // 🟢 procinfo: wraps a byte slice as an in-memory io.Reader; no I/O side effects. + "context.Context", // 🟢 procinfo: deadline/cancellation interface; no side effects. + "encoding/binary.BigEndian", // 🟢 winnet: reads big-endian IPv6 group values from DLL buffer; pure value, no I/O. + "encoding/binary.LittleEndian", // 🟢 winnet: reads little-endian DWORD fields from DLL buffer; pure value, no I/O. + "errors.Is", // 🟢 procinfo: checks whether an error in a chain matches a target; pure function, no I/O. + "errors.New", // 🟢 creates a sentinel error; pure function, no I/O. + "github.com/spf13/pflag.ContinueOnError", // 🟢 flagparser: pflag parsing-mode constant; pure constant, no I/O. + "github.com/spf13/pflag.FlagSet", // 🟢 flagparser: pflag FlagSet type used to trial-parse argv prefixes; pure type, no I/O. + "github.com/spf13/pflag.NewFlagSet", // 🟢 flagparser: constructs a throw-away FlagSet for trial-parsing; pure constructor, no I/O. + "io.Discard", // 🟢 flagparser: silences trial.SetOutput so trial-parse failures don't leak to stderr; pure no-op writer. + "math/bits.OnesCount32", // 🟢 procnet: counts set bits in a uint32 (popcount for prefix length); pure function, no I/O. + "math/bits.ReverseBytes32", // 🟢 procnet: byte-swaps a uint32 to convert little-endian /proc mask to network byte order for CIDR validation; pure function, no I/O. + "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. + "os.ErrNotExist", // 🟢 procinfo: sentinel error value indicating a file or directory does not exist; read-only constant, no I/O. + "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. + "io.EOF", // 🟢 vmstat: sentinel error value returned by bufio.Scanner at end of file; pure constant. + "io.LimitReader", // 🟢 procsyskernel: wraps a reader with a byte cap; pure wrapper, no I/O by itself. + "io.ReadAll", // 🟠 procsyskernel: reads all data from a bounded reader; used with LimitReader for 4KiB cap. + "io.Reader", // 🟢 diskstats: interface type used to feed parseMountInfo from arbitrary readers; pure type, no I/O. + "os.Getpagesize", // 🟢 vmstat: returns the host's memory page size; read-only, no I/O. + "os.Getpid", // 🟠 procinfo: returns the current process ID; read-only, no side effects. + "os.ModeCharDevice", // 🟢 procsyskernel: file mode constant for char device detection; pure constant. + "os.O_RDONLY", // 🟢 procsyskernel: read-only open flag; pure constant. + "os.Open", // 🟠 procinfo: opens a file read-only; needed to stream /proc/stat line-by-line. + "os.OpenFile", // 🟠 procsyskernel: opens kernel pseudo-files with O_NONBLOCK; bypasses AllowedPaths by design. + "os.ReadDir", // 🟠 procinfo: reads a directory listing; needed to enumerate /proc entries. + "os.ReadFile", // 🟠 procinfo: reads a whole file; needed to read /proc/[pid]/{stat,status}. + "os.Stat", // 🟠 procinfo: validates that the proc path exists before enumeration; read-only metadata, no write capability. + "path/filepath.Base", // 🟢 procsyskernel: returns the last element of a path; validates name is a plain basename. + "path/filepath.Clean", // 🟢 procnetroute/procnetsocket: normalises procPath before ".." safety check; pure function, no I/O. + "path/filepath.Join", // 🟢 procinfo: joins path elements to construct /proc//stat paths; pure function, no I/O. + "strconv.Atoi", // 🟢 string-to-int conversion; pure function, no I/O. + "strconv.Itoa", // 🟢 procinfo: int-to-string conversion for PID directory names; pure function, no I/O. + "strconv.ParseInt", // 🟢 procinfo: string to int64 with base/bit-size; pure function, no I/O. + "strconv.ParseFloat", // 🟢 vmstat: parses load-average and uptime float fields; pure function, no I/O. + "strconv.FormatUint", // 🟢 procnetsocket: uint-to-string conversion for port/inode formatting; pure function, no I/O. + "strconv.ParseUint", // 🟢 procnetroute/procnetsocket: parses hex/decimal route and socket fields; pure function, no I/O. + "strings.Builder", // 🟢 procnetsocket/diskstats: efficient string concatenation; pure in-memory buffer, no I/O. + "strings.Contains", // 🟢 procnetroute/diskstats: substring check; pure function, no I/O. + "strings.ContainsRune", // 🟢 diskstats: fast-path check for backslash before unescape; pure function, no I/O. + "strings.Cut", // 🟢 diskstats: splits a string at the first separator; pure function, no I/O. + "strings.Fields", // 🟢 procinfo/procnetroute/procnetsocket/diskstats: splits a string on whitespace; pure function, no I/O. + "strings.Join", // 🟢 procnetsocket: reconstructs space-containing Unix socket paths from Fields tokens; pure function, no I/O. + "strings.Split", // 🟢 procnetsocket: splits address:port fields on ":"; pure function, no I/O. + "strings.ToUpper", // 🟢 procnetsocket: normalises hex state field to uppercase for map lookup; pure function, no I/O. + "strings.CutPrefix", // 🟢 flagparser: trims known pflag error prefixes before rewriting; pure function, no I/O. + "strings.HasPrefix", // 🟢 procinfo/diskstats: checks string prefix; pure function, no I/O. + "strings.HasSuffix", // 🟢 flagparser: matches pflag error suffixes (e.g. "flag does not allow an argument"); pure function, no I/O. + "strings.Index", // 🟢 procinfo: finds first occurrence of a substring; pure function, no I/O. + "strings.LastIndex", // 🟢 procinfo: finds last occurrence of a substring; pure function, no I/O. + "strings.TrimRight", // 🟢 procinfo: trims trailing characters; pure function, no I/O. + "strings.TrimSpace", // 🟢 procinfo: removes leading/trailing whitespace; pure function, no I/O. + "syscall.Errno", // 🟢 winnet: wraps DLL return code as an error type; pure type, no I/O. + "syscall.Getsid", // 🟠 procinfo: returns the session ID of a process; read-only syscall, no write/exec. + "syscall.O_NONBLOCK", // 🟢 procsyskernel: non-blocking open flag to prevent FIFO hang; pure constant. + "syscall.MustLoadDLL", // 🔴 winnet: loads iphlpapi.dll once at program init; read-only OS loader call. + "syscall.Proc", // 🟢 winnet: DLL procedure handle type used in function signature; pure type, no I/O. + "time.Now", // 🟠 procinfo: returns the current wall-clock time; read-only, no side effects. + "time.Unix", // 🟢 procinfo: constructs a Time from Unix seconds; pure function, no I/O. + "unsafe.Pointer", // 🔴 winnet: passes buffer/size pointers to DLL via syscall ABI. No pointer arithmetic; buffer parsed with encoding/binary after the call. + "golang.org/x/sys/unix.ByteSliceToString", // 🟢 diskstats (darwin): converts a NUL-terminated kernel byte buffer to a Go string; pure function, no I/O. + "golang.org/x/sys/unix.Getfsstat", // 🟠 diskstats (darwin): read-only enumeration of mounted filesystems via getfsstat(2); no exec or write capability. + "golang.org/x/sys/unix.Getpagesize", // 🟢 vmstat (darwin): returns the host's memory page size; read-only, no I/O. + "golang.org/x/sys/unix.KinfoProc", // 🟢 procinfo (darwin): struct type carrying per-process kinfo_proc data from sysctl; read-only data, no exec capability. + "golang.org/x/sys/unix.MNT_LOCAL", // 🟢 diskstats (darwin): flag constant indicating a local-only filesystem; pure constant. + "golang.org/x/sys/unix.MNT_NOWAIT", // 🟢 diskstats (darwin): flag constant: do not block on remote FS for getfsstat; pure constant. + "golang.org/x/sys/unix.Statfs", // 🟠 diskstats (linux): read-only filesystem usage syscall; no exec or write capability. + "golang.org/x/sys/unix.Statfs_t", // 🟢 diskstats: struct type carrying filesystem usage data from statfs/getfsstat; pure data type. + "golang.org/x/sys/unix.SysctlKinfoProc", // 🟠 procinfo (darwin): reads a single process's kinfo_proc via kern.proc.pid sysctl; read-only, no exec or write capability. + "golang.org/x/sys/unix.SysctlKinfoProcSlice", // 🟠 procinfo (darwin): reads all processes' kinfo_proc via kern.proc.all sysctl; read-only, no exec or write capability. + "golang.org/x/sys/unix.SysctlRaw", // 🟠 vmstat (darwin): reads raw sysctl byte replies (vm.swapusage, vm.loadavg); read-only, no exec or write capability. + "golang.org/x/sys/unix.SysctlUint64", // 🟠 vmstat (darwin): reads a uint64 sysctl value (hw.memsize); read-only, no exec or write capability. + "golang.org/x/sys/windows.CloseHandle", // 🟠 procinfo (windows): closes a process-snapshot handle after enumeration; no data read or exec capability. "golang.org/x/sys/windows.CreateToolhelp32Snapshot", // 🟠 procinfo (windows): creates a read-only snapshot of the process table; no exec or write capability. "golang.org/x/sys/windows.ERROR_BROKEN_PIPE", // 🟢 winpoll (windows): sentinel error from PeekNamedPipe when the writer end has closed — used to recognize EOF-ready pipes; pure constant. "golang.org/x/sys/windows.ERROR_NO_MORE_FILES", // 🟢 procinfo (windows): sentinel error indicating end of process enumeration; pure constant. diff --git a/builtins/internal/vmstat/vmstat.go b/builtins/internal/vmstat/vmstat.go new file mode 100644 index 00000000..f09e5add --- /dev/null +++ b/builtins/internal/vmstat/vmstat.go @@ -0,0 +1,150 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package vmstat reads virtual-memory, swap, IO-paging, and CPU pressure +// counters from the kernel and presents them as a normalised cross-platform +// Stats struct. +// +// This package lives under builtins/internal/ and is therefore exempt from +// the builtinAllowedSymbols allowlist check. It may use OS-specific APIs +// freely. +// +// # Sandbox bypass +// +// The Linux backend reads /proc/stat, /proc/meminfo, /proc/vmstat, and +// /proc/loadavg via os.Open directly, intentionally bypassing the +// AllowedPaths sandbox (callCtx.OpenFile). These paths are kernel-managed +// pseudo-files hardcoded by this package and never derived from user +// input, so AllowedPaths restrictions do not apply. This matches the +// documented exception used by the ss, ip route, and df builtins. +// +// # Platform coverage +// +// Linux populates every field group. macOS populates memory totals, swap +// totals, and load averages via sysctl(3) — the same darwin toolset the ss +// and df builtins already use (golang.org/x/sys/unix, no Mach calls). Per +// page-state memory breakdown (active/inactive/wired), CPU tick counters, +// and paging/interrupt/context-switch rates are not available through +// sysctl and are reported as zero with Partial cleared for those groups; +// see Stats.Partial. Other platforms return ErrNotSupported. +// +// # Memory and CPU bounds +// +// The Linux backend bounds both the per-line buffer size and the total +// number of lines scanned per pseudo-file, so a pathological /proc content +// cannot exhaust memory or CPU. +package vmstat + +import ( + "context" + "errors" +) + +// ErrNotSupported is returned by Read on platforms where no backend is +// implemented. +var ErrNotSupported = errors.New("not supported on this platform") + +// Fields is a bitmask identifying which groups of Stats are populated by +// the current platform's backend. Callers must not treat a field outside +// Stats.Partial as a genuine zero measurement. +type Fields uint32 + +const ( + // FieldProcs covers ProcsRunning / ProcsBlocked. + FieldProcs Fields = 1 << iota + // FieldMemory covers MemTotal / MemFree / MemBuffers / MemCached / + // MemActive / MemInactive. + FieldMemory + // FieldSwap covers SwapTotal / SwapFree. + FieldSwap + // FieldPaging covers PagesInKB / PagesOutKB / SwapInPages / SwapOutPages. + FieldPaging + // FieldSystem covers Interrupts / ContextSwitches. + FieldSystem + // FieldCPU covers CPUUser / CPUNice / CPUSystem / CPUIdle / CPUIOWait / + // CPUIRQ / CPUSoftIRQ / CPUSteal. + FieldCPU + // FieldLoadAvg covers LoadAvg1 / LoadAvg5 / LoadAvg15. + FieldLoadAvg +) + +// AllFields is every field group. The Linux backend sets this. +const AllFields = FieldProcs | FieldMemory | FieldSwap | FieldPaging | FieldSystem | FieldCPU | FieldLoadAvg + +// Stats holds host-pressure counters. Unless noted, values are cumulative +// counters since boot (matching /proc/*'s semantics); the vmstat builtin is +// responsible for turning them into rates via repeated sampling. +type Stats struct { + // ProcsRunning is the number of processes in a runnable state. + ProcsRunning uint64 + // ProcsBlocked is the number of processes blocked on uninterruptible IO. + ProcsBlocked uint64 + + // Memory, in bytes. + MemTotal uint64 + MemFree uint64 + MemBuffers uint64 + MemCached uint64 + MemActive uint64 + MemInactive uint64 + + // Swap, in bytes. + SwapTotal uint64 + SwapFree uint64 + + // Paging counters, cumulative since boot. + // PagesInKB / PagesOutKB are kibibytes paged in/out from/to disk + // (matches /proc/vmstat's pgpgin/pgpgout, which are already KiB). + PagesInKB uint64 + PagesOutKB uint64 + // SwapInPages / SwapOutPages are page counts swapped in/out (matches + // /proc/vmstat's pswpin/pswpout). Multiply by PageSize to get bytes. + SwapInPages uint64 + SwapOutPages uint64 + + // System counters, cumulative since boot. + Interrupts uint64 + ContextSwitches uint64 + + // CPU ticks, cumulative since boot. Divide by ClockTicksPerSec to get + // seconds, or take deltas across two samples for a utilization ratio. + CPUUser uint64 + CPUNice uint64 + CPUSystem uint64 + CPUIdle uint64 + CPUIOWait uint64 + CPUIRQ uint64 + CPUSoftIRQ uint64 + CPUSteal uint64 + + // ClockTicksPerSec is the unit for the CPU tick fields above. + ClockTicksPerSec uint64 + // PageSize is the host's memory page size, in bytes. + PageSize uint64 + + // LoadAvg1 / LoadAvg5 / LoadAvg15 are the 1/5/15-minute load averages. + LoadAvg1, LoadAvg5, LoadAvg15 float64 + + // Uptime is the number of seconds since boot, used to compute + // since-boot per-second averages for the rate columns (si/so/bi/bo/ + // in/cs/us/sy/id/wa/st) in a single-sample snapshot. Zero when + // unavailable (e.g. macOS); callers must treat 0 as "unknown", not a + // literal just-booted host. + Uptime float64 + + // Partial reports which field groups the current platform populated. + // Fields outside this mask are always zero and must be presented as + // "not available" (e.g. "-") rather than as a genuine zero reading. + Partial Fields +} + +// Read collects a point-in-time snapshot of host-pressure counters. +// procPath is the proc filesystem root (e.g. "/proc"); it is ignored on +// platforms that do not use /proc. +// +// On unsupported platforms it returns (Stats{}, ErrNotSupported). +func Read(ctx context.Context, procPath string) (Stats, error) { + return readImpl(ctx, procPath) +} diff --git a/builtins/internal/vmstat/vmstat_darwin.go b/builtins/internal/vmstat/vmstat_darwin.go new file mode 100644 index 00000000..07be7b74 --- /dev/null +++ b/builtins/internal/vmstat/vmstat_darwin.go @@ -0,0 +1,155 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build darwin + +package vmstat + +import ( + "context" + + "golang.org/x/sys/unix" +) + +// readImpl assembles Stats on macOS from sysctl(3) only — the same darwin +// toolset the ss and df builtins already use (golang.org/x/sys/unix, +// SysctlRaw + manual byte decoding). No Mach host_statistics64 call is +// made: it would be a new, undocumented syscall surface, and no existing +// rshell builtin uses it. Per-page memory breakdown (active/inactive/ +// wired), CPU tick counters, and paging/interrupt/context-switch rates are +// therefore unavailable on macOS; Partial reflects that. +// +// procPath is unused on darwin. +func readImpl(_ context.Context, _ string) (Stats, error) { + var st Stats + st.PageSize = uint64(unix.Getpagesize()) + + memOK := readMemory(&st) + swapOK := readSwap(&st) + loadOK := readLoadAvg(&st) + + if memOK { + st.Partial |= FieldMemory + } + if swapOK { + st.Partial |= FieldSwap + } + if loadOK { + st.Partial |= FieldLoadAvg + } + if st.Partial == 0 { + return Stats{}, ErrNotSupported + } + return st, nil +} + +// readMemory populates MemTotal via hw.memsize. macOS has no sysctl +// exposing system-wide free/buffers/cached without Mach host_statistics64 +// (see package doc), so MemFree/MemBuffers/MemCached/MemActive/MemInactive +// stay zero. +func readMemory(st *Stats) bool { + total, err := unix.SysctlUint64("hw.memsize") + if err != nil { + return false + } + st.MemTotal = total + return true +} + +// xswUsageSize is sizeof(struct xsw_usage) from : +// +// struct xsw_usage { +// u_int64_t xsu_total; +// u_int64_t xsu_avail; +// u_int64_t xsu_used; +// u_int32_t xsu_pagesize; +// boolean_t xsu_encrypted; // 4-byte int +// }; +const xswUsageSize = 8 + 8 + 8 + 4 + 4 + +// readSwap populates SwapTotal/SwapFree via vm.swapusage. +func readSwap(st *Stats) bool { + data, err := unix.SysctlRaw("vm.swapusage") + if err != nil { + return false + } + return decodeSwapUsage(data, st) +} + +// decodeSwapUsage decodes a raw vm.swapusage sysctl reply (struct +// xsw_usage) into st. Split out from readSwap so tests can exercise the +// byte layout with a synthetic buffer instead of live sysctl output. +func decodeSwapUsage(data []byte, st *Stats) bool { + if len(data) < xswUsageSize { + return false + } + total := readU64LE(data, 0) + used := readU64LE(data, 16) + st.SwapTotal = total + if used <= total { + st.SwapFree = total - used + } + return true +} + +// loadavgStructSize is sizeof(struct loadavg) from : +// +// struct loadavg { +// fixpt_t ldavg[3]; // u_int32_t, fixed-point scaled by fscale +// long fscale; // 8 bytes on 64-bit darwin, at offset 16 after +// // 4 bytes of padding to satisfy long's 8-byte +// // alignment +// }; +const loadavgStructSize = 24 + +// readLoadAvg populates LoadAvg1/5/15 via vm.loadavg. +func readLoadAvg(st *Stats) bool { + data, err := unix.SysctlRaw("vm.loadavg") + if err != nil { + return false + } + return decodeLoadAvg(data, st) +} + +// decodeLoadAvg decodes a raw vm.loadavg sysctl reply (struct loadavg) +// into st. Split out from readLoadAvg so tests can exercise the byte +// layout with a synthetic buffer instead of live sysctl output. +func decodeLoadAvg(data []byte, st *Stats) bool { + if len(data) < loadavgStructSize { + return false + } + fscale := readU64LE(data, 16) + if fscale == 0 { + return false + } + l1 := readU32LE(data, 0) + l5 := readU32LE(data, 4) + l15 := readU32LE(data, 8) + st.LoadAvg1 = float64(l1) / float64(fscale) + st.LoadAvg5 = float64(l5) / float64(fscale) + st.LoadAvg15 = float64(l15) / float64(fscale) + return true +} + +// readU32LE reads a little-endian uint32 from data at offset off. Returns 0 +// if the read would run past the end of data. +func readU32LE(data []byte, off int) uint32 { + if off+4 > len(data) { + return 0 + } + return uint32(data[off]) | + uint32(data[off+1])<<8 | + uint32(data[off+2])<<16 | + uint32(data[off+3])<<24 +} + +// readU64LE reads a little-endian uint64 from data at offset off. Returns 0 +// if the read would run past the end of data. +func readU64LE(data []byte, off int) uint64 { + if off+8 > len(data) { + return 0 + } + return uint64(readU32LE(data, off)) | uint64(readU32LE(data, off+4))<<32 +} diff --git a/builtins/internal/vmstat/vmstat_darwin_test.go b/builtins/internal/vmstat/vmstat_darwin_test.go new file mode 100644 index 00000000..b5b24462 --- /dev/null +++ b/builtins/internal/vmstat/vmstat_darwin_test.go @@ -0,0 +1,129 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build darwin + +package vmstat + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadImpl_Darwin_HappyPath(t *testing.T) { + st, err := Read(context.Background(), "") + require.NoError(t, err) + + assert.NotZero(t, st.Partial&FieldMemory, "macOS should always report FieldMemory via hw.memsize") + assert.NotZero(t, st.MemTotal, "hw.memsize should be non-zero on any real Mac") + assert.NotZero(t, st.Partial&FieldSwap, "macOS should always report FieldSwap via vm.swapusage") + assert.NotZero(t, st.Partial&FieldLoadAvg, "macOS should always report FieldLoadAvg via vm.loadavg") + assert.GreaterOrEqual(t, st.LoadAvg1, 0.0) + + // Groups with no sysctl backing on macOS must stay unset — a caller + // that ignores Partial and prints these as "0" would be lying about + // actual host state. + assert.Zero(t, st.Partial&FieldProcs) + assert.Zero(t, st.Partial&FieldPaging) + assert.Zero(t, st.Partial&FieldSystem) + assert.Zero(t, st.Partial&FieldCPU) +} + +func TestReadU32LE(t *testing.T) { + data := []byte{0x01, 0x02, 0x03, 0x04} + assert.EqualValues(t, 0x04030201, readU32LE(data, 0)) + assert.EqualValues(t, 0, readU32LE(data, 1), "out-of-range read returns 0, not a panic") + assert.EqualValues(t, 0, readU32LE(data, 4)) + assert.EqualValues(t, 0, readU32LE(nil, 0)) +} + +func TestReadU64LE(t *testing.T) { + data := make([]byte, 8) + data[0] = 0xFF + data[7] = 0x01 + assert.EqualValues(t, 0x01000000000000FF, readU64LE(data, 0)) + assert.EqualValues(t, 0, readU64LE(data, 1)) + assert.EqualValues(t, 0, readU64LE(nil, 0)) +} + +func TestReadSwap_SyntheticBuffer(t *testing.T) { + // struct xsw_usage { u_int64 total; u_int64 avail; u_int64 used; u_int32 pagesize; boolean_t encrypted; } + buf := make([]byte, xswUsageSize) + putU64LE(buf, 0, 8_000_000_000) // total + putU64LE(buf, 8, 6_000_000_000) // avail (unused by us) + putU64LE(buf, 16, 1_000_000_000) // used + + var st Stats + ok := decodeSwapUsage(buf, &st) + require.True(t, ok) + assert.EqualValues(t, 8_000_000_000, st.SwapTotal) + assert.EqualValues(t, 7_000_000_000, st.SwapFree) +} + +func TestReadSwap_TooShort(t *testing.T) { + var st Stats + ok := decodeSwapUsage(make([]byte, xswUsageSize-1), &st) + assert.False(t, ok) +} + +func TestReadSwap_UsedExceedsTotal(t *testing.T) { + // A malformed/adversarial sysctl reply where used > total must not + // underflow SwapFree. + buf := make([]byte, xswUsageSize) + putU64LE(buf, 0, 100) + putU64LE(buf, 16, 200) + + var st Stats + ok := decodeSwapUsage(buf, &st) + require.True(t, ok) + assert.EqualValues(t, 100, st.SwapTotal) + assert.EqualValues(t, 0, st.SwapFree, "used > total must clamp to 0, not wrap around") +} + +func TestReadLoadAvg_SyntheticBuffer(t *testing.T) { + buf := make([]byte, loadavgStructSize) + // fscale = 2048 (LSCALE on darwin); ldavg values are fixed-point scaled. + putU32LE(buf, 0, 246) // 246/2048 ≈ 0.12 + putU32LE(buf, 4, 697) // ≈ 0.34 + putU32LE(buf, 8, 1147) // ≈ 0.56 + putU64LE(buf, 16, 2048) + + var st Stats + ok := decodeLoadAvg(buf, &st) + require.True(t, ok) + assert.InDelta(t, 0.12, st.LoadAvg1, 0.001) + assert.InDelta(t, 0.34, st.LoadAvg5, 0.001) + assert.InDelta(t, 0.56, st.LoadAvg15, 0.001) +} + +func TestReadLoadAvg_ZeroFscale(t *testing.T) { + buf := make([]byte, loadavgStructSize) // fscale defaults to 0 + var st Stats + ok := decodeLoadAvg(buf, &st) + assert.False(t, ok, "fscale=0 must be rejected, not divide-by-zero") +} + +func TestReadLoadAvg_TooShort(t *testing.T) { + var st Stats + ok := decodeLoadAvg(make([]byte, loadavgStructSize-1), &st) + assert.False(t, ok) +} + +// putU32LE / putU64LE are the test-side mirror of readU32LE / readU64LE, used +// to build synthetic sysctl-shaped buffers without depending on encoding/binary. +func putU32LE(buf []byte, off int, v uint32) { + buf[off] = byte(v) + buf[off+1] = byte(v >> 8) + buf[off+2] = byte(v >> 16) + buf[off+3] = byte(v >> 24) +} + +func putU64LE(buf []byte, off int, v uint64) { + putU32LE(buf, off, uint32(v)) + putU32LE(buf, off+4, uint32(v>>32)) +} diff --git a/builtins/internal/vmstat/vmstat_linux.go b/builtins/internal/vmstat/vmstat_linux.go new file mode 100644 index 00000000..75ec097c --- /dev/null +++ b/builtins/internal/vmstat/vmstat_linux.go @@ -0,0 +1,313 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build linux + +package vmstat + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +// clockTicksPerSec is the kernel's clock-tick rate (sysconf(_SC_CLK_TCK)). +// This is 100 on essentially every Linux platform rshell targets; the +// procinfo package (used by ps) hardcodes the same value for the same +// reason: there is no allocation-free, syscall-free way to query +// _SC_CLK_TCK from pure Go without cgo. +const clockTicksPerSec = 100 + +// maxLineLen bounds the per-line buffer size when scanning /proc/{stat, +// meminfo,vmstat,loadavg}. Lines longer than this abort the scan for that +// file (best-effort: the fields already parsed are kept). +const maxLineLen = 1 << 16 // 64 KiB + +// maxLines bounds the total number of lines scanned per file. Real kernels +// emit at most a few hundred lines even on very large machines (one line +// per CPU in /proc/stat); this is a defensive ceiling against a +// pathological /proc mount, not a realistic limit. +const maxLines = 100_000 + +// readImpl assembles Stats from /proc/stat, /proc/meminfo, /proc/vmstat, +// and /proc/loadavg. Every file is opened directly via os.Open — this is +// the documented sandbox-bypass exception also used by diskstats, +// procnetsocket, and procsyskernel: the paths are hardcoded kernel +// pseudo-files, never derived from user input. +// +// Missing or unreadable files are tolerated: their field group is simply +// left out of the Partial mask so the caller can render "-" instead of a +// false zero. +func readImpl(ctx context.Context, procPath string) (Stats, error) { + if err := ctx.Err(); err != nil { + return Stats{}, err + } + + var st Stats + st.ClockTicksPerSec = clockTicksPerSec + st.PageSize = uint64(os.Getpagesize()) + + if err := readProcStat(ctx, filepath.Join(procPath, "stat"), &st); err == nil { + st.Partial |= FieldProcs | FieldSystem | FieldCPU + } + if err := readProcMeminfo(ctx, filepath.Join(procPath, "meminfo"), &st); err == nil { + st.Partial |= FieldMemory | FieldSwap + } + if err := readProcVmstat(ctx, filepath.Join(procPath, "vmstat"), &st); err == nil { + st.Partial |= FieldPaging + } + if err := readProcLoadavg(ctx, filepath.Join(procPath, "loadavg"), &st); err == nil { + st.Partial |= FieldLoadAvg + } + // Uptime is best-effort and does not gate Partial: it only feeds the + // since-boot rate-average computation in the builtin's single-sample + // snapshot mode, and a missing /proc/uptime should not hide the rest + // of the (successfully read) fields. + _ = readProcUptime(ctx, filepath.Join(procPath, "uptime"), &st) + + if st.Partial == 0 { + return Stats{}, fmt.Errorf("vmstat: no /proc/{stat,meminfo,vmstat,loadavg} data available under %s", procPath) + } + return st, nil +} + +// scanBounded runs fn once per line of path, using a buffer capped at +// maxLineLen and a total-line ceiling of maxLines. It is the shared +// bounded-read primitive for every /proc file this package parses. +func scanBounded(ctx context.Context, path string, fn func(line string)) error { + f, err := os.Open(path) //nolint:gosec // hardcoded kernel pseudo-file; see package doc. + if err != nil { + return err + } + defer f.Close() //nolint:errcheck + + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 4096), maxLineLen) + lines := 0 + for sc.Scan() { + if err := ctx.Err(); err != nil { + return err + } + lines++ + if lines > maxLines { + break + } + fn(sc.Text()) + } + if err := sc.Err(); err != nil && err != io.EOF { + return err + } + return nil +} + +// readProcStat parses procs_running, procs_blocked, intr, ctxt, and the +// aggregate "cpu " line from /proc/stat. +func readProcStat(ctx context.Context, path string, st *Stats) error { + found := false + err := scanBounded(ctx, path, func(line string) { + switch { + case strings.HasPrefix(line, "cpu "): + fields := strings.Fields(line) + // fields[0] == "cpu"; user nice system idle iowait irq softirq steal ... + vals := parseUint64Fields(fields[1:], 8) + st.CPUUser, st.CPUNice, st.CPUSystem, st.CPUIdle = vals[0], vals[1], vals[2], vals[3] + st.CPUIOWait, st.CPUIRQ, st.CPUSoftIRQ, st.CPUSteal = vals[4], vals[5], vals[6], vals[7] + found = true + case strings.HasPrefix(line, "intr "): + st.Interrupts = parseUint64Field(line) + found = true + case strings.HasPrefix(line, "ctxt "): + st.ContextSwitches = parseUint64Field(line) + found = true + case strings.HasPrefix(line, "procs_running "): + st.ProcsRunning = parseUint64Field(line) + found = true + case strings.HasPrefix(line, "procs_blocked "): + st.ProcsBlocked = parseUint64Field(line) + found = true + } + }) + if err != nil { + return err + } + if !found { + return fmt.Errorf("vmstat: no recognised fields in %s", path) + } + return nil +} + +// readProcMeminfo parses the memory and swap fields from /proc/meminfo. +// Values in the file are in KiB; they are converted to bytes. +func readProcMeminfo(ctx context.Context, path string, st *Stats) error { + found := false + err := scanBounded(ctx, path, func(line string) { + key, kb, ok := parseMeminfoLine(line) + if !ok { + return + } + bytes := kb * 1024 + switch key { + case "MemTotal": + st.MemTotal, found = bytes, true + case "MemFree": + st.MemFree, found = bytes, true + case "Buffers": + st.MemBuffers, found = bytes, true + case "Cached": + st.MemCached, found = bytes, true + case "Active": + st.MemActive, found = bytes, true + case "Inactive": + st.MemInactive, found = bytes, true + case "SwapTotal": + st.SwapTotal, found = bytes, true + case "SwapFree": + st.SwapFree, found = bytes, true + } + }) + if err != nil { + return err + } + if !found { + return fmt.Errorf("vmstat: no recognised fields in %s", path) + } + return nil +} + +// parseMeminfoLine splits a "Key: 123 kB" line into its key and +// numeric value (in KiB). Returns ok=false for malformed lines. +func parseMeminfoLine(line string) (key string, kb uint64, ok bool) { + k, rest, found := strings.Cut(line, ":") + if !found || k == "" { + return "", 0, false + } + fields := strings.Fields(rest) + if len(fields) == 0 { + return "", 0, false + } + v, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return "", 0, false + } + return k, v, true +} + +// readProcVmstat parses pgpgin/pgpgout/pswpin/pswpout from /proc/vmstat. +func readProcVmstat(ctx context.Context, path string, st *Stats) error { + found := false + err := scanBounded(ctx, path, func(line string) { + fields := strings.Fields(line) + if len(fields) != 2 { + return + } + v, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return + } + switch fields[0] { + case "pgpgin": + st.PagesInKB, found = v, true + case "pgpgout": + st.PagesOutKB, found = v, true + case "pswpin": + st.SwapInPages, found = v, true + case "pswpout": + st.SwapOutPages, found = v, true + } + }) + if err != nil { + return err + } + if !found { + return fmt.Errorf("vmstat: no recognised fields in %s", path) + } + return nil +} + +// readProcLoadavg parses the three load averages from /proc/loadavg +// ("0.12 0.34 0.56 1/234 5678"). +func readProcLoadavg(ctx context.Context, path string, st *Stats) error { + found := false + err := scanBounded(ctx, path, func(line string) { + fields := strings.Fields(line) + if len(fields) < 3 { + return + } + l1, err1 := strconv.ParseFloat(fields[0], 64) + l5, err5 := strconv.ParseFloat(fields[1], 64) + l15, err15 := strconv.ParseFloat(fields[2], 64) + if err1 != nil || err5 != nil || err15 != nil { + return + } + st.LoadAvg1, st.LoadAvg5, st.LoadAvg15 = l1, l5, l15 + found = true + }) + if err != nil { + return err + } + if !found { + return fmt.Errorf("vmstat: no recognised fields in %s", path) + } + return nil +} + +// readProcUptime parses the first field of /proc/uptime (seconds since +// boot). The second field (idle-time seconds) is ignored. +func readProcUptime(ctx context.Context, path string, st *Stats) error { + found := false + err := scanBounded(ctx, path, func(line string) { + fields := strings.Fields(line) + if len(fields) < 1 { + return + } + v, err := strconv.ParseFloat(fields[0], 64) + if err != nil || v < 0 { + return + } + st.Uptime = v + found = true + }) + if err != nil { + return err + } + if !found { + return fmt.Errorf("vmstat: no recognised fields in %s", path) + } + return nil +} + +// parseUint64Field parses the single numeric value out of a "key value" +// line (e.g. "ctxt 987654"). Returns 0 on any parse failure. +func parseUint64Field(line string) uint64 { + fields := strings.Fields(line) + if len(fields) < 2 { + return 0 + } + v, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return 0 + } + return v +} + +// parseUint64Fields parses up to n numeric fields, returning a slice of +// length n. Missing or unparsable fields are left as 0 so a short or +// malformed "cpu " line never panics on index-out-of-range. +func parseUint64Fields(fields []string, n int) []uint64 { + out := make([]uint64, n) + for i := 0; i < n && i < len(fields); i++ { + v, err := strconv.ParseUint(fields[i], 10, 64) + if err != nil { + continue + } + out[i] = v + } + return out +} diff --git a/builtins/internal/vmstat/vmstat_linux_fuzz_test.go b/builtins/internal/vmstat/vmstat_linux_fuzz_test.go new file mode 100644 index 00000000..3604011d --- /dev/null +++ b/builtins/internal/vmstat/vmstat_linux_fuzz_test.go @@ -0,0 +1,89 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build linux + +package vmstat + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// FuzzParseMeminfoLine exercises the "Key: value kB" line parser. +// +// Seed corpus draws from three sources, per the skill protocol: +// - Implementation edge cases: missing colon, missing value, non-numeric +// value, trailing "kB" unit, no unit. +// - CVE / security history: embedded NUL, CRLF, invalid UTF-8, very long +// lines, huge numeric values (overflow probing). +// - Existing test coverage: every line from sampleMeminfo. +func FuzzParseMeminfoLine(f *testing.F) { + f.Add("MemTotal: 8000000 kB") + f.Add("MemFree: 2000000 kB") + f.Add("Buffers: 100000 kB") + f.Add("SwapTotal: 0 kB") + f.Add("NoColon 123 kB") + f.Add("Key:") + f.Add("Key: kB") + f.Add("Key: notanumber kB") + f.Add("Key: 123") + f.Add("Key: 18446744073709551615 kB") // MaxUint64 + f.Add("Key: 99999999999999999999999 kB") // overflows uint64 + f.Add("Key: -1 kB") + f.Add("") + f.Add(":") + f.Add("::::") + f.Add("Key:\x00123 kB") + f.Add("Key: 123 kB\r") + f.Add("Key: 123 \xff\xfe") + + f.Fuzz(func(t *testing.T, line string) { + if len(line) > 1<<16 { + return + } + // MUST: never panics, and any returned kb is only trusted when ok. + key, kb, ok := parseMeminfoLine(line) + if !ok { + return + } + if key == "" { + t.Fatalf("parseMeminfoLine returned ok=true with empty key for %q", line) + } + _ = kb + }) +} + +// FuzzReadProcStat feeds arbitrary content as a synthetic /proc/stat file +// and asserts readProcStat never panics and never returns a Stats with +// fields set unless it reports success. +func FuzzReadProcStat(f *testing.F) { + f.Add([]byte("")) + f.Add([]byte("\n")) + f.Add([]byte("cpu \n")) + f.Add([]byte("cpu 1 2 3 4 5 6 7 8 9 10\n")) + f.Add([]byte("cpu 99999999999999999999 2 3\n")) // overflow + f.Add([]byte("intr\n")) + f.Add([]byte("ctxt notanumber\n")) + f.Add([]byte("procs_running -1\n")) + f.Add([]byte(sampleProcStat)) + f.Add([]byte("\x00\x00\x00\n")) + f.Add([]byte("cpu 1 2 3 4 5 6 7 8\r\n")) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 1<<20 { + return + } + dir := t.TempDir() + path := filepath.Join(dir, "stat") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Skip() + } + var st Stats + _ = readProcStat(context.Background(), path, &st) // MUST NOT panic + }) +} diff --git a/builtins/internal/vmstat/vmstat_linux_parse_test.go b/builtins/internal/vmstat/vmstat_linux_parse_test.go new file mode 100644 index 00000000..0498998f --- /dev/null +++ b/builtins/internal/vmstat/vmstat_linux_parse_test.go @@ -0,0 +1,195 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build linux + +package vmstat + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const sampleProcStat = `cpu 201313 3153 46753 1049295 3200 0 1465 0 0 0 +cpu0 100000 1500 20000 500000 1600 0 700 0 0 0 +intr 1234567 0 0 0 +ctxt 987654 +btime 1600000000 +processes 12345 +procs_running 2 +procs_blocked 1 +softirq 555555 0 1 2 3 +` + +const sampleMeminfo = `MemTotal: 8000000 kB +MemFree: 2000000 kB +MemAvailable: 4000000 kB +Buffers: 100000 kB +Cached: 1500000 kB +SwapCached: 0 kB +Active: 3000000 kB +Inactive: 1000000 kB +SwapTotal: 2000000 kB +SwapFree: 1900000 kB +` + +const sampleVmstat = `nr_free_pages 500000 +pgpgin 123456 +pgpgout 654321 +pswpin 10 +pswpout 20 +pgfault 999 +` + +const sampleLoadavg = `0.12 0.34 0.56 1/234 5678 +` + +const sampleUptime = `12345.67 98765.43 +` + +func writeTemp(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) +} + +func TestReadImpl_HappyPath(t *testing.T) { + dir := t.TempDir() + writeTemp(t, dir, "stat", sampleProcStat) + writeTemp(t, dir, "meminfo", sampleMeminfo) + writeTemp(t, dir, "vmstat", sampleVmstat) + writeTemp(t, dir, "loadavg", sampleLoadavg) + writeTemp(t, dir, "uptime", sampleUptime) + + st, err := readImpl(context.Background(), dir) + require.NoError(t, err) + assert.Equal(t, AllFields, st.Partial) + assert.InDelta(t, 12345.67, st.Uptime, 1e-9) + + assert.EqualValues(t, 2, st.ProcsRunning) + assert.EqualValues(t, 1, st.ProcsBlocked) + assert.EqualValues(t, 1234567, st.Interrupts) + assert.EqualValues(t, 987654, st.ContextSwitches) + assert.EqualValues(t, 201313, st.CPUUser) + assert.EqualValues(t, 3153, st.CPUNice) + assert.EqualValues(t, 46753, st.CPUSystem) + assert.EqualValues(t, 1049295, st.CPUIdle) + assert.EqualValues(t, 3200, st.CPUIOWait) + assert.EqualValues(t, 1465, st.CPUSoftIRQ) + + assert.EqualValues(t, 8000000*1024, st.MemTotal) + assert.EqualValues(t, 2000000*1024, st.MemFree) + assert.EqualValues(t, 100000*1024, st.MemBuffers) + assert.EqualValues(t, 1500000*1024, st.MemCached) + assert.EqualValues(t, 3000000*1024, st.MemActive) + assert.EqualValues(t, 1000000*1024, st.MemInactive) + assert.EqualValues(t, 2000000*1024, st.SwapTotal) + assert.EqualValues(t, 1900000*1024, st.SwapFree) + + assert.EqualValues(t, 123456, st.PagesInKB) + assert.EqualValues(t, 654321, st.PagesOutKB) + assert.EqualValues(t, 10, st.SwapInPages) + assert.EqualValues(t, 20, st.SwapOutPages) + + assert.InDelta(t, 0.12, st.LoadAvg1, 1e-9) + assert.InDelta(t, 0.34, st.LoadAvg5, 1e-9) + assert.InDelta(t, 0.56, st.LoadAvg15, 1e-9) + + assert.EqualValues(t, clockTicksPerSec, st.ClockTicksPerSec) + assert.Greater(t, st.PageSize, uint64(0)) +} + +func TestReadImpl_PartialWhenFilesMissing(t *testing.T) { + dir := t.TempDir() + writeTemp(t, dir, "meminfo", sampleMeminfo) + // stat, vmstat, loadavg are absent. + + st, err := readImpl(context.Background(), dir) + require.NoError(t, err) + assert.Equal(t, FieldMemory|FieldSwap, st.Partial) + assert.EqualValues(t, 0, st.ProcsRunning, "no /proc/stat means procs fields stay zero") +} + +func TestReadImpl_AllFilesMissing(t *testing.T) { + dir := t.TempDir() + _, err := readImpl(context.Background(), dir) + assert.Error(t, err) +} + +func TestReadImpl_ContextCancelled(t *testing.T) { + dir := t.TempDir() + writeTemp(t, dir, "stat", sampleProcStat) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := readImpl(ctx, dir) + assert.Error(t, err) +} + +func TestReadProcStat_MalformedCPULine(t *testing.T) { + dir := t.TempDir() + writeTemp(t, dir, "stat", "cpu 1 2\nprocs_running 3\n") + + var st Stats + err := readProcStat(context.Background(), filepath.Join(dir, "stat"), &st) + require.NoError(t, err) + assert.EqualValues(t, 1, st.CPUUser) + assert.EqualValues(t, 2, st.CPUNice) + assert.EqualValues(t, 0, st.CPUSystem, "missing trailing fields default to 0, not a panic") + assert.EqualValues(t, 3, st.ProcsRunning) +} + +func TestParseMeminfoLine(t *testing.T) { + key, kb, ok := parseMeminfoLine("MemTotal: 8000000 kB") + assert.True(t, ok) + assert.Equal(t, "MemTotal", key) + assert.EqualValues(t, 8000000, kb) + + _, _, ok = parseMeminfoLine("garbage line with no colon") + assert.False(t, ok) + + _, _, ok = parseMeminfoLine("MemTotal: notanumber kB") + assert.False(t, ok) +} + +func TestScanBounded_RespectsLineCeiling(t *testing.T) { + dir := t.TempDir() + content := strings.Repeat("procs_running 1\n", maxLines+10) + writeTemp(t, dir, "stat", content) + + seen := 0 + err := scanBounded(context.Background(), filepath.Join(dir, "stat"), func(string) { seen++ }) + require.NoError(t, err) + assert.LessOrEqual(t, seen, maxLines) +} + +func TestReadProcUptime(t *testing.T) { + dir := t.TempDir() + writeTemp(t, dir, "uptime", sampleUptime) + + var st Stats + err := readProcUptime(context.Background(), filepath.Join(dir, "uptime"), &st) + require.NoError(t, err) + assert.InDelta(t, 12345.67, st.Uptime, 1e-9) +} + +func TestReadProcUptime_NegativeRejected(t *testing.T) { + dir := t.TempDir() + writeTemp(t, dir, "uptime", "-5 0\n") + + var st Stats + err := readProcUptime(context.Background(), filepath.Join(dir, "uptime"), &st) + assert.Error(t, err) +} + +func TestScanBounded_MissingFile(t *testing.T) { + err := scanBounded(context.Background(), "/nonexistent/path/for/vmstat/test", func(string) {}) + assert.Error(t, err) +} diff --git a/builtins/internal/vmstat/vmstat_other.go b/builtins/internal/vmstat/vmstat_other.go new file mode 100644 index 00000000..d8349d96 --- /dev/null +++ b/builtins/internal/vmstat/vmstat_other.go @@ -0,0 +1,16 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !linux && !darwin + +package vmstat + +import "context" + +// readImpl returns ErrNotSupported on platforms without a backend +// (Windows and anything else). Mirrors diskstats_other.go. +func readImpl(_ context.Context, _ string) (Stats, error) { + return Stats{}, ErrNotSupported +} diff --git a/builtins/tests/vmstat/helpers_test.go b/builtins/tests/vmstat/helpers_test.go new file mode 100644 index 00000000..d73a16b5 --- /dev/null +++ b/builtins/tests/vmstat/helpers_test.go @@ -0,0 +1,57 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package vmstat_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +func runScript(t *testing.T, script string) (stdout, stderr string, exitCode int) { + t.Helper() + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader(script), "") + if err != nil { + t.Fatal(err) + } + var outBuf, errBuf bytes.Buffer + runner, err := interp.New( + interp.StdIO(nil, &outBuf, &errBuf), + interpoption.AllowAllCommands().(interp.RunnerOption), + ) + if err != nil { + t.Fatal(err) + } + defer runner.Close() + runErr := runner.Run(context.Background(), prog) + code := 0 + if runErr != nil { + var es interp.ExitStatus + if errors.As(runErr, &es) { + code = int(es) + } else { + t.Fatalf("unexpected error: %v", runErr) + } + } + return outBuf.String(), errBuf.String(), code +} + +// cmdRun runs a vmstat command. vmstat does not access the filesystem via +// the AllowedPaths sandbox (it delegates to the internal vmstat package, +// which reads hardcoded kernel pseudo-files directly), so no AllowedPaths +// configuration is needed here. +func cmdRun(t *testing.T, script string) (stdout, stderr string, exitCode int) { + t.Helper() + return runScript(t, script) +} diff --git a/builtins/tests/vmstat/vmstat_linux_test.go b/builtins/tests/vmstat/vmstat_linux_test.go new file mode 100644 index 00000000..741ccff0 --- /dev/null +++ b/builtins/tests/vmstat/vmstat_linux_test.go @@ -0,0 +1,227 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build linux + +package vmstat_test + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + vmstatcmd "github.com/DataDog/rshell/builtins/vmstat" +) + +// procPathMu serializes mutations of vmstatcmd.ProcPath across all tests in +// this package. Any code that writes to ProcPath must hold this lock for the +// duration of the test to prevent data races if tests are run in parallel. +// Tests in this package must NOT call t.Parallel() — doing so would cause +// test goroutines to block indefinitely on procPathMu.Lock() while another +// test holds the lock. +var procPathMu sync.Mutex + +const syntheticProcStat = `cpu 201313 3153 46753 1049295 3200 0 1465 0 0 0 +intr 1234567 0 0 0 +ctxt 987654 +btime 1600000000 +processes 12345 +procs_running 2 +procs_blocked 1 +softirq 555555 0 1 2 3 +` + +const syntheticMeminfo = `MemTotal: 8000000 kB +MemFree: 2000000 kB +MemAvailable: 4000000 kB +Buffers: 100000 kB +Cached: 1500000 kB +SwapCached: 0 kB +Active: 3000000 kB +Inactive: 1000000 kB +SwapTotal: 2000000 kB +SwapFree: 1900000 kB +` + +const syntheticVmstat = `nr_free_pages 500000 +pgpgin 123456 +pgpgout 654321 +pswpin 10 +pswpout 20 +pgfault 999 +` + +const syntheticLoadavg = `0.12 0.34 0.56 1/234 5678 +` + +const syntheticUptime = `12345.67 98765.43 +` + +// writeSyntheticProc writes a synthetic /proc tree (stat/meminfo/vmstat/ +// loadavg/uptime) to a temp directory, patches vmstatcmd.ProcPath to point at +// it, and restores the original path via t.Cleanup. +// +// It acquires procPathMu for the duration of the test to prevent data races. +func writeSyntheticProc(t *testing.T) { + t.Helper() + dir := t.TempDir() + write := func(name, content string) { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) + } + write("stat", syntheticProcStat) + write("meminfo", syntheticMeminfo) + write("vmstat", syntheticVmstat) + write("loadavg", syntheticLoadavg) + write("uptime", syntheticUptime) + + procPathMu.Lock() + orig := vmstatcmd.ProcPath + vmstatcmd.ProcPath = dir + t.Cleanup(func() { + vmstatcmd.ProcPath = orig + procPathMu.Unlock() + }) +} + +func TestVmstatSnapshotDefault(t *testing.T) { + writeSyntheticProc(t) + stdout, stderr, code := cmdRun(t, "vmstat") + require.Equal(t, 0, code, "stderr: %s", stderr) + lines := splitLines(stdout) + require.Len(t, lines, 3, "header (2 lines) + one data row") + assert.Contains(t, lines[0], "procs") + assert.Contains(t, lines[0], "memory") + assert.Contains(t, lines[0], "swap") + assert.Contains(t, lines[0], "io") + assert.Contains(t, lines[0], "system") + assert.Contains(t, lines[0], "cpu") + assert.Contains(t, lines[1], "r") + assert.Contains(t, lines[1], "swpd") + assert.Contains(t, lines[1], "us") +} + +func TestVmstatSnapshotActiveMemory(t *testing.T) { + writeSyntheticProc(t) + stdout, stderr, code := cmdRun(t, "vmstat -a") + require.Equal(t, 0, code, "stderr: %s", stderr) + assert.Contains(t, stdout, "inact") + assert.Contains(t, stdout, "active") +} + +func TestVmstatSnapshotWide(t *testing.T) { + writeSyntheticProc(t) + narrow, _, code1 := cmdRun(t, "vmstat") + require.Equal(t, 0, code1) + wide, stderr, code2 := cmdRun(t, "vmstat -w") + require.Equal(t, 0, code2, "stderr: %s", stderr) + assert.Greater(t, len(wide), len(narrow), "wide output should use wider columns") +} + +func TestVmstatSnapshotUnitScale(t *testing.T) { + writeSyntheticProc(t) + stdoutK, _, code1 := cmdRun(t, "vmstat -S K") + require.Equal(t, 0, code1) + stdoutM, stderr, code2 := cmdRun(t, "vmstat -S M") + require.Equal(t, 0, code2, "stderr: %s", stderr) + assert.NotEqual(t, stdoutK, stdoutM, "different -S units should scale memory columns differently") +} + +func TestVmstatInvalidUnit(t *testing.T) { + writeSyntheticProc(t) + _, stderr, code := cmdRun(t, "vmstat -S bogus") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "invalid unit") +} + +func TestVmstatStatsFlag(t *testing.T) { + writeSyntheticProc(t) + stdout, stderr, code := cmdRun(t, "vmstat -s") + require.Equal(t, 0, code, "stderr: %s", stderr) + assert.Contains(t, stdout, "total memory") + assert.Contains(t, stdout, "total swap") + assert.Contains(t, stdout, "runnable processes") + assert.Contains(t, stdout, "CPU user ticks") + assert.Contains(t, stdout, "minute load average") +} + +func TestVmstatStatsRejectsExtraOperand(t *testing.T) { + writeSyntheticProc(t) + _, stderr, code := cmdRun(t, "vmstat -s 1") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "extra operand") +} + +func TestVmstatSamplingWithCount(t *testing.T) { + writeSyntheticProc(t) + stdout, stderr, code := cmdRun(t, "vmstat 1 3") + require.Equal(t, 0, code, "stderr: %s", stderr) + lines := splitLines(stdout) + require.Len(t, lines, 5, "header (2 lines) + 3 data rows") +} + +func TestVmstatInvalidDelay(t *testing.T) { + writeSyntheticProc(t) + _, stderr, code := cmdRun(t, "vmstat abc") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "invalid delay") +} + +func TestVmstatInvalidCount(t *testing.T) { + writeSyntheticProc(t) + _, stderr, code := cmdRun(t, "vmstat 1 0") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "invalid count") +} + +func TestVmstatExtraOperand(t *testing.T) { + writeSyntheticProc(t) + _, stderr, code := cmdRun(t, "vmstat 1 2 3") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "extra operand") +} + +func TestVmstatHelp(t *testing.T) { + writeSyntheticProc(t) + stdout, stderr, code := cmdRun(t, "vmstat --help") + require.Equal(t, 0, code, "stderr: %s", stderr) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "Usage: vmstat") + assert.Contains(t, stdout, "--stats") +} + +func TestVmstatUnknownFlagRejected(t *testing.T) { + writeSyntheticProc(t) + _, stderr, code := cmdRun(t, "vmstat -d") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "invalid option -- 'd'") +} + +func TestVmstatMissingProcFilesShowsDashes(t *testing.T) { + dir := t.TempDir() + // Only meminfo is present; stat/vmstat/loadavg/uptime are absent, so the + // procs/swap-rate/io/system/cpu column groups must render as dashes + // instead of fabricated zeros. + require.NoError(t, os.WriteFile(filepath.Join(dir, "meminfo"), []byte(syntheticMeminfo), 0o644)) + + procPathMu.Lock() + orig := vmstatcmd.ProcPath + vmstatcmd.ProcPath = dir + t.Cleanup(func() { + vmstatcmd.ProcPath = orig + procPathMu.Unlock() + }) + stdout, stderr, code := cmdRun(t, "vmstat") + require.Equal(t, 0, code, "stderr: %s", stderr) + assert.Contains(t, stdout, "-", "missing counter groups should render as dashes, not panic or fabricate zeros") +} + +func splitLines(s string) []string { + return strings.Split(strings.TrimRight(s, "\n"), "\n") +} diff --git a/builtins/vmstat/vmstat.go b/builtins/vmstat/vmstat.go new file mode 100644 index 00000000..70dddd62 --- /dev/null +++ b/builtins/vmstat/vmstat.go @@ -0,0 +1,536 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package vmstat implements the vmstat builtin command. +// +// vmstat — report virtual memory, swap, IO, and CPU pressure statistics +// +// Usage: vmstat [OPTION]... [delay [count]] +// +// With no arguments, prints a single snapshot averaged since boot. With +// delay (whole seconds), samples repeatedly, printing a since-boot average +// as the first row and true deltas between samples thereafter; count +// limits the number of rows printed (default: run until the shell's +// execution timeout or the caller interrupts the command). +// +// Counter collection is delegated to the internal vmstat package, which +// reads /proc/{stat,meminfo,vmstat,loadavg} on Linux (exempt from the +// AllowedPaths sandbox — same documented exception used by df, ss, and +// ip route: the paths are hardcoded and never derived from user input) +// and sysctl(3) on macOS (hw.memsize, vm.swapusage, vm.loadavg — the same +// darwin toolset already used by df and ss). See +// builtins/internal/vmstat's package doc for the full platform-coverage +// rationale. +// +// # Platform limitations +// +// macOS has no sysctl exposing per-page memory breakdown (buffers/cache/ +// active/inactive beyond totals), CPU tick counters, or paging/interrupt/ +// context-switch counters without a Mach host_statistics64 call, which +// this implementation intentionally does not make (see the internal +// package doc). On macOS those columns print as "-" when the whole +// counter group is unavailable (procs/swap-rate/io/system/cpu); memory +// and swap totals print normally. +// +// Accepted flags: +// +// -a, --active +// Display active/inactive memory instead of buffers/cache. +// +// -w, --wide +// Use wider field widths (avoids truncating large numbers). +// +// -S, --unit=k|K|m|M +// Scale memory columns: k=1000 bytes, K=1024 (default), m=1e6, +// M=2^20. +// +// -s, --stats +// Print a full set of event counters, one label per line, instead +// of the column report. Ignores delay/count. +// +// -h, --help +// Print usage to stdout and exit 0. +// +// Rejected flags (intentionally not registered, rejected as unknown by +// pflag with exit 1): +// +// -d, -p — disk/partition statistics; not implemented in v1. +// -f — fork counts since boot; not implemented in v1. +// -m — slab info; not implemented in v1. +// -t — timestamp column; not implemented in v1. +// -n — single-header mode; not meaningful (every invocation is +// already a single process). +// -V, --version — not meaningful in this shell. +// +// Exit codes: +// +// 0 Success — a report was written. +// 1 Error — unsupported platform, unknown flag, extra operand, or +// failure to read counters. +package vmstat + +import ( + "context" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/DataDog/rshell/builtins" + "github.com/DataDog/rshell/builtins/internal/procpath" + ivmstat "github.com/DataDog/rshell/builtins/internal/vmstat" +) + +// Cmd is the vmstat builtin command descriptor. +var Cmd = builtins.Command{ + Name: "vmstat", + Description: "report virtual memory, swap, IO, and CPU pressure statistics", + MakeFlags: makeFlags, +} + +// ProcPath is the proc filesystem root passed to ivmstat.Read. It is a +// package-level variable so tests can point it at a synthetic directory +// instead of the real /proc. +// +// Concurrency contract: this variable is written only in tests and is never +// mutated by production code after package initialization. Test code that +// mutates ProcPath must hold a test-package-level mutex for the duration of +// the test to prevent data races between concurrent test goroutines. +var ProcPath = procpath.Default + +// flags carries the parsed flag state for one invocation. +type flags struct { + help *bool + active *bool + wide *bool + stats *bool + unit *string +} + +func makeFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + f := &flags{ + help: fs.BoolP("help", "h", false, "print usage and exit"), + active: fs.BoolP("active", "a", false, "display active and inactive memory instead of buffers and cache"), + wide: fs.BoolP("wide", "w", false, "use wider field widths"), + stats: fs.BoolP("stats", "s", false, "display a full set of event-counter statistics, one per line"), + unit: fs.StringP("unit", "S", "K", "scale memory columns by unit: k|K|m|M (1000|1024|1e6|2^20 bytes)"), + } + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *f.help { + printHelp(callCtx, fs) + return builtins.Result{} + } + + divisor, err := unitDivisor(*f.unit) + if err != nil { + callCtx.Errf("vmstat: %v\n", err) + callCtx.Errf("Try 'vmstat --help' for more information.\n") + return builtins.Result{Code: 1} + } + + if *f.stats { + if len(args) > 0 { + callCtx.Errf("vmstat: extra operand '%s'\n", args[0]) + return builtins.Result{Code: 1} + } + return runStats(ctx, callCtx, divisor) + } + + delay, count, err := parseSamplingArgs(args) + if err != nil { + callCtx.Errf("vmstat: %v\n", err) + return builtins.Result{Code: 1} + } + return runSampling(ctx, callCtx, *f.active, *f.wide, divisor, delay, count) + } +} + +// unitDivisor maps the -S argument to a byte divisor. +func unitDivisor(s string) (int64, error) { + switch s { + case "k": + return 1000, nil + case "K": + return 1024, nil + case "m": + return 1_000_000, nil + case "M": + return 1_048_576, nil + default: + return 0, fmt.Errorf("invalid unit '%s' (expected k, K, m, or M)", s) + } +} + +// parseSamplingArgs validates the positional [delay [count]] operands. +// delay=0 signals "no sampling requested" (single snapshot); count=0 +// signals "unbounded" (sample until ctx is done). +func parseSamplingArgs(args []string) (delay time.Duration, count int, err error) { + if len(args) == 0 { + return 0, 1, nil + } + if len(args) > 2 { + return 0, 0, fmt.Errorf("extra operand '%s'", args[2]) + } + d, convErr := strconv.ParseUint(args[0], 10, 32) + if convErr != nil || d == 0 { + return 0, 0, fmt.Errorf("invalid delay '%s'", args[0]) + } + if len(args) == 2 { + c, convErr := strconv.ParseUint(args[1], 10, 32) + if convErr != nil || c == 0 { + return 0, 0, fmt.Errorf("invalid count '%s'", args[1]) + } + count = int(c) + } + return time.Duration(d) * time.Second, count, nil +} + +// runSampling prints the header, an initial since-boot-average row, and +// (when delay > 0) further delta rows spaced delay apart, until count +// rows have been printed (count == 0 means "until ctx is done"). +func runSampling(ctx context.Context, callCtx *builtins.CallContext, active, wide bool, divisor int64, delay time.Duration, count int) builtins.Result { + printHeader(callCtx, active, wide) + + first, err := ivmstat.Read(ctx, ProcPath) + if err != nil { + callCtx.Errf("vmstat: %v\n", err) + return builtins.Result{Code: 1} + } + callCtx.Out(formatRow(first, nil, first.Uptime, divisor, active, wide)) + + if delay == 0 { + return builtins.Result{} + } + + prev := first + for i := 1; count == 0 || i < count; i++ { + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return builtins.Result{Code: 1} + case <-timer.C: + } + + cur, err := ivmstat.Read(ctx, ProcPath) + if err != nil { + callCtx.Errf("vmstat: %v\n", err) + return builtins.Result{Code: 1} + } + callCtx.Out(formatRow(cur, &prev, delay.Seconds(), divisor, active, wide)) + prev = cur + } + return builtins.Result{} +} + +// runStats implements -s/--stats: one labeled counter per line. +func runStats(ctx context.Context, callCtx *builtins.CallContext, divisor int64) builtins.Result { + st, err := ivmstat.Read(ctx, ProcPath) + if err != nil { + callCtx.Errf("vmstat: %v\n", err) + return builtins.Result{Code: 1} + } + + hasMem := st.Partial&ivmstat.FieldMemory != 0 + hasSwap := st.Partial&ivmstat.FieldSwap != 0 + hasProcs := st.Partial&ivmstat.FieldProcs != 0 + hasPaging := st.Partial&ivmstat.FieldPaging != 0 + hasSystem := st.Partial&ivmstat.FieldSystem != 0 + hasCPU := st.Partial&ivmstat.FieldCPU != 0 + hasLoad := st.Partial&ivmstat.FieldLoadAvg != 0 + + u := uint64(divisor) + line := func(ok bool, v uint64, label string) { + if !ok { + callCtx.Outf("%12s %s\n", "-", label) + return + } + callCtx.Outf("%12d %s\n", v, label) + } + lineFloat := func(ok bool, v float64, label string) { + if !ok { + callCtx.Outf("%12s %s\n", "-", label) + return + } + callCtx.Outf("%12.2f %s\n", v, label) + } + + line(hasMem, st.MemTotal/u, "K total memory") + line(hasMem, subClamp(st.MemTotal, st.MemFree)/u, "K used memory") + line(hasMem, st.MemActive/u, "K active memory") + line(hasMem, st.MemInactive/u, "K inactive memory") + line(hasMem, st.MemFree/u, "K free memory") + line(hasMem, st.MemBuffers/u, "K buffer memory") + line(hasMem, st.MemCached/u, "K swap cache") + line(hasSwap, st.SwapTotal/u, "K total swap") + line(hasSwap, subClamp(st.SwapTotal, st.SwapFree)/u, "K used swap") + line(hasSwap, st.SwapFree/u, "K free swap") + line(hasProcs, st.ProcsRunning, "runnable processes") + line(hasProcs, st.ProcsBlocked, "processes blocked waiting for I/O") + line(hasPaging, st.PagesInKB, "K paged in from disk") + line(hasPaging, st.PagesOutKB, "K paged out to disk") + line(hasPaging, st.SwapInPages, "pages swapped in") + line(hasPaging, st.SwapOutPages, "pages swapped out") + line(hasSystem, st.Interrupts, "interrupts") + line(hasSystem, st.ContextSwitches, "CPU context switches") + line(hasCPU, st.CPUUser, "CPU user ticks") + line(hasCPU, st.CPUNice, "CPU nice ticks") + line(hasCPU, st.CPUSystem, "CPU system ticks") + line(hasCPU, st.CPUIdle, "CPU idle ticks") + line(hasCPU, st.CPUIOWait, "CPU I/O-wait ticks") + line(hasCPU, st.CPUSteal, "CPU steal ticks") + lineFloat(hasLoad, st.LoadAvg1, "1 minute load average") + lineFloat(hasLoad, st.LoadAvg5, "5 minute load average") + lineFloat(hasLoad, st.LoadAvg15, "15 minute load average") + + return builtins.Result{} +} + +// group describes one header/column group (e.g. "memory": swpd, free, +// buff, cache). +type group struct { + label string + names []string + width int +} + +func buildGroups(active, wide bool) []group { + w := func(base int) int { + if wide { + return base + 4 + } + return base + } + memNames := []string{"swpd", "free", "buff", "cache"} + if active { + memNames = []string{"swpd", "free", "inact", "active"} + } + return []group{ + {"procs", []string{"r", "b"}, w(3)}, + {"memory", memNames, w(7)}, + {"swap", []string{"si", "so"}, w(4)}, + {"io", []string{"bi", "bo"}, w(5)}, + {"system", []string{"in", "cs"}, w(4)}, + {"cpu", []string{"us", "sy", "id", "wa", "st"}, w(3)}, + } +} + +func printHeader(callCtx *builtins.CallContext, active, wide bool) { + groups := buildGroups(active, wide) + l1 := make([]string, 0, len(groups)) + l2 := make([]string, 0, len(groups)*2) + for _, g := range groups { + total := g.width*len(g.names) + (len(g.names) - 1) + l1 = append(l1, groupHeaderCell(g.label, total)) + for _, n := range g.names { + l2 = append(l2, fmt.Sprintf("%*s", g.width, n)) + } + } + callCtx.Out(strings.Join(l1, " ") + "\n") + callCtx.Out(strings.Join(l2, " ") + "\n") +} + +// groupHeaderCell renders one top-level header cell. "procs" has no +// dashes (matches procps: it is not an averaged/rate group); every other +// group is centered within a run of dashes. +func groupHeaderCell(label string, total int) string { + if label == "procs" { + if len(label) >= total { + return label[:total] + } + return label + strings.Repeat(" ", total-len(label)) + } + if len(label) >= total { + return label[:total] + } + pad := total - len(label) + left := pad / 2 + right := pad - left + return strings.Repeat("-", left) + label + strings.Repeat("-", right) +} + +// formatRow renders one data row. prev == nil means "since-boot average" +// (elapsedSeconds should be cur.Uptime); prev != nil means "delta between +// prev and cur" (elapsedSeconds should be the nominal sample interval). +func formatRow(cur ivmstat.Stats, prev *ivmstat.Stats, elapsedSeconds float64, divisor int64, active, wide bool) string { + groups := buildGroups(active, wide) + cells := make([]string, 0, 16) + + // procs: instantaneous counts, not rates. + w := groups[0].width + if cur.Partial&ivmstat.FieldProcs != 0 { + cells = append(cells, fmtUint(cur.ProcsRunning, w), fmtUint(cur.ProcsBlocked, w)) + } else { + cells = append(cells, dash(w), dash(w)) + } + + // memory: instantaneous sizes, not rates. + w = groups[1].width + if cur.Partial&(ivmstat.FieldMemory|ivmstat.FieldSwap) != 0 { + swpd := subClamp(cur.SwapTotal, cur.SwapFree) + cells = append(cells, fmtScaled(swpd, divisor, w), fmtScaled(cur.MemFree, divisor, w)) + if active { + cells = append(cells, fmtScaled(cur.MemInactive, divisor, w), fmtScaled(cur.MemActive, divisor, w)) + } else { + cells = append(cells, fmtScaled(cur.MemBuffers, divisor, w), fmtScaled(cur.MemCached, divisor, w)) + } + } else { + cells = append(cells, dash(w), dash(w), dash(w), dash(w)) + } + + // swap: si/so, in KB/sec. + w = groups[2].width + if cur.Partial&ivmstat.FieldPaging != 0 && elapsedSeconds > 0 { + si, so := rateSwap(cur, prev, elapsedSeconds) + cells = append(cells, fmtUint(si, w), fmtUint(so, w)) + } else { + cells = append(cells, dash(w), dash(w)) + } + + // io: bi/bo, in KB/sec. + w = groups[3].width + if cur.Partial&ivmstat.FieldPaging != 0 && elapsedSeconds > 0 { + bi, bo := rateIO(cur, prev, elapsedSeconds) + cells = append(cells, fmtUint(bi, w), fmtUint(bo, w)) + } else { + cells = append(cells, dash(w), dash(w)) + } + + // system: in/cs, per second. + w = groups[4].width + if cur.Partial&ivmstat.FieldSystem != 0 && elapsedSeconds > 0 { + in, cs := rateSystem(cur, prev, elapsedSeconds) + cells = append(cells, fmtUint(in, w), fmtUint(cs, w)) + } else { + cells = append(cells, dash(w), dash(w)) + } + + // cpu: us/sy/id/wa/st, as percentages of ticks elapsed. + w = groups[5].width + if cur.Partial&ivmstat.FieldCPU != 0 { + us, sy, id, wa, st := cpuPercents(cur, prev) + cells = append(cells, fmtInt(us, w), fmtInt(sy, w), fmtInt(id, w), fmtInt(wa, w), fmtInt(st, w)) + } else { + cells = append(cells, dash(w), dash(w), dash(w), dash(w), dash(w)) + } + + return strings.Join(cells, " ") + "\n" +} + +func fmtUint(v uint64, width int) string { + return fmt.Sprintf("%*d", width, v) +} + +func fmtInt(v int64, width int) string { + return fmt.Sprintf("%*d", width, v) +} + +func fmtScaled(v uint64, divisor int64, width int) string { + return fmt.Sprintf("%*d", width, v/uint64(divisor)) +} + +func dash(width int) string { + return fmt.Sprintf("%*s", width, "-") +} + +// subClamp returns a-b, clamped to 0 instead of underflowing when b > a +// (e.g. an adversarial or momentarily-inconsistent kernel counter read). +func subClamp(a, b uint64) uint64 { + if a >= b { + return a - b + } + return 0 +} + +// rateSwap computes si/so (KB/sec swapped in/out) either since boot +// (prev == nil) or as a delta between two samples. +func rateSwap(cur ivmstat.Stats, prev *ivmstat.Stats, elapsedSeconds float64) (si, so uint64) { + inPages, outPages := cur.SwapInPages, cur.SwapOutPages + if prev != nil { + inPages = subClamp(cur.SwapInPages, prev.SwapInPages) + outPages = subClamp(cur.SwapOutPages, prev.SwapOutPages) + } + kb := cur.PageSize / 1024 + if kb == 0 { + kb = 1 + } + si = uint64(float64(inPages*kb) / elapsedSeconds) + so = uint64(float64(outPages*kb) / elapsedSeconds) + return si, so +} + +// rateIO computes bi/bo (KB/sec paged in/out from/to disk). +func rateIO(cur ivmstat.Stats, prev *ivmstat.Stats, elapsedSeconds float64) (bi, bo uint64) { + inKB, outKB := cur.PagesInKB, cur.PagesOutKB + if prev != nil { + inKB = subClamp(cur.PagesInKB, prev.PagesInKB) + outKB = subClamp(cur.PagesOutKB, prev.PagesOutKB) + } + bi = uint64(float64(inKB) / elapsedSeconds) + bo = uint64(float64(outKB) / elapsedSeconds) + return bi, bo +} + +// rateSystem computes in/cs (interrupts and context switches per second). +func rateSystem(cur ivmstat.Stats, prev *ivmstat.Stats, elapsedSeconds float64) (in, cs uint64) { + intr, ctxt := cur.Interrupts, cur.ContextSwitches + if prev != nil { + intr = subClamp(cur.Interrupts, prev.Interrupts) + ctxt = subClamp(cur.ContextSwitches, prev.ContextSwitches) + } + in = uint64(float64(intr) / elapsedSeconds) + cs = uint64(float64(ctxt) / elapsedSeconds) + return in, cs +} + +// cpuPercents computes us/sy/id/wa/st as percentages of the ticks elapsed +// either since boot (prev == nil) or between two samples. Nice ticks are +// folded into "us" and IRQ/softIRQ ticks are folded into "sy", matching +// procps vmstat's five-column CPU breakdown. +func cpuPercents(cur ivmstat.Stats, prev *ivmstat.Stats) (us, sy, id, wa, st int64) { + userT := cur.CPUUser + cur.CPUNice + sysT := cur.CPUSystem + cur.CPUIRQ + cur.CPUSoftIRQ + idleT := cur.CPUIdle + waT := cur.CPUIOWait + stT := cur.CPUSteal + if prev != nil { + userT = subClamp(userT, prev.CPUUser+prev.CPUNice) + sysT = subClamp(sysT, prev.CPUSystem+prev.CPUIRQ+prev.CPUSoftIRQ) + idleT = subClamp(idleT, prev.CPUIdle) + waT = subClamp(waT, prev.CPUIOWait) + stT = subClamp(stT, prev.CPUSteal) + } + total := userT + sysT + idleT + waT + stT + if total == 0 { + return 0, 0, 100, 0, 0 + } + us = pct(userT, total) + sy = pct(sysT, total) + wa = pct(waT, total) + st = pct(stT, total) + id = 100 - us - sy - wa - st + if id < 0 { + id = 0 + } + return us, sy, id, wa, st +} + +func pct(v, total uint64) int64 { + return int64(math.Round(float64(v) * 100 / float64(total))) +} + +// printHelp emits the help text to stdout (per RULES.md, help is not an +// error; exit 0 with output on stdout). +func printHelp(callCtx *builtins.CallContext, fs *builtins.FlagSet) { + callCtx.Out("Usage: vmstat [OPTION]... [delay [count]]\n") + callCtx.Out("Report virtual memory, swap, IO, and CPU pressure statistics.\n\n") + callCtx.Out("With no arguments, print one snapshot averaged since boot.\n") + callCtx.Out("With delay (whole seconds), sample repeatedly; count limits the\n") + callCtx.Out("number of samples printed (default: until interrupted).\n\n") + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() +} diff --git a/interp/register_builtins.go b/interp/register_builtins.go index 6d16ce2f..259c3b4e 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -42,6 +42,7 @@ import ( "github.com/DataDog/rshell/builtins/truncate" "github.com/DataDog/rshell/builtins/uname" "github.com/DataDog/rshell/builtins/uniq" + "github.com/DataDog/rshell/builtins/vmstat" "github.com/DataDog/rshell/builtins/wc" "github.com/DataDog/rshell/builtins/xargs" ) @@ -85,6 +86,7 @@ func registerBuiltins() { truncate.Cmd, uname.Cmd, uniq.Cmd, + vmstat.Cmd, wc.Cmd, xargs.Cmd, } { diff --git a/tests/scenarios/cmd/vmstat/basic/sampling.yaml b/tests/scenarios/cmd/vmstat/basic/sampling.yaml new file mode 100644 index 00000000..f53bdc72 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/basic/sampling.yaml @@ -0,0 +1,13 @@ +description: vmstat with a delay and count samples repeatedly, printing exactly count data rows. +# skip: vmstat is not present in debian:bookworm-slim, and output reflects +# live host state. +skip_assert_against_bash: true +input: + script: |+ + vmstat 1 2 | wc -l; echo "code:$?" +expect: + stdout_contains: + - "4" + - "code:0" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/vmstat/basic/snapshot.yaml b/tests/scenarios/cmd/vmstat/basic/snapshot.yaml new file mode 100644 index 00000000..8467d2d7 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/basic/snapshot.yaml @@ -0,0 +1,15 @@ +description: vmstat with no arguments prints a two-line header and one data row. +# skip: vmstat is not present in debian:bookworm-slim (it ships in procps, +# not installed there), and output reflects live host state. +skip_assert_against_bash: true +input: + script: |+ + vmstat; echo "code:$?" +expect: + stdout_contains: + - "procs" + - "memory" + - "swap" + - "code:0" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/vmstat/basic/stats.yaml b/tests/scenarios/cmd/vmstat/basic/stats.yaml new file mode 100644 index 00000000..b6a91e69 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/basic/stats.yaml @@ -0,0 +1,14 @@ +description: vmstat -s prints a labeled counter summary, one entry per line. +# skip: vmstat is not present in debian:bookworm-slim, and counters reflect +# live host state. +skip_assert_against_bash: true +input: + script: |+ + vmstat -s; echo "code:$?" +expect: + stdout_contains: + - "total memory" + - "total swap" + - "code:0" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/vmstat/errors/extra_operand.yaml b/tests/scenarios/cmd/vmstat/errors/extra_operand.yaml new file mode 100644 index 00000000..6d0e0ffd --- /dev/null +++ b/tests/scenarios/cmd/vmstat/errors/extra_operand.yaml @@ -0,0 +1,10 @@ +description: vmstat with more than two positional operands exits 1 with an "extra operand" error. +skip_assert_against_bash: true +input: + script: |+ + vmstat 1 2 3 +expect: + stdout: "" + stderr_contains: + - "vmstat: extra operand" + exit_code: 1 diff --git a/tests/scenarios/cmd/vmstat/errors/invalid_count.yaml b/tests/scenarios/cmd/vmstat/errors/invalid_count.yaml new file mode 100644 index 00000000..874448c4 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/errors/invalid_count.yaml @@ -0,0 +1,10 @@ +description: vmstat with a zero count operand exits 1 with an "invalid count" error. +skip_assert_against_bash: true +input: + script: |+ + vmstat 1 0 +expect: + stdout: "" + stderr_contains: + - "vmstat: invalid count" + exit_code: 1 diff --git a/tests/scenarios/cmd/vmstat/errors/invalid_delay.yaml b/tests/scenarios/cmd/vmstat/errors/invalid_delay.yaml new file mode 100644 index 00000000..f035d266 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/errors/invalid_delay.yaml @@ -0,0 +1,10 @@ +description: vmstat with a non-numeric delay operand exits 1 with an "invalid delay" error. +skip_assert_against_bash: true +input: + script: |+ + vmstat abc +expect: + stdout: "" + stderr_contains: + - "vmstat: invalid delay" + exit_code: 1 diff --git a/tests/scenarios/cmd/vmstat/errors/invalid_unit.yaml b/tests/scenarios/cmd/vmstat/errors/invalid_unit.yaml new file mode 100644 index 00000000..2e70f2e5 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/errors/invalid_unit.yaml @@ -0,0 +1,10 @@ +description: vmstat -S with an unrecognized unit exits 1 with an "invalid unit" error. +skip_assert_against_bash: true +input: + script: |+ + vmstat -S bogus +expect: + stdout: "" + stderr_contains: + - "vmstat: invalid unit" + exit_code: 1 diff --git a/tests/scenarios/cmd/vmstat/errors/stats_extra_operand.yaml b/tests/scenarios/cmd/vmstat/errors/stats_extra_operand.yaml new file mode 100644 index 00000000..72155d30 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/errors/stats_extra_operand.yaml @@ -0,0 +1,10 @@ +description: vmstat -s rejects a positional operand (delay/count are meaningless with -s/--stats). +skip_assert_against_bash: true +input: + script: |+ + vmstat -s 1 +expect: + stdout: "" + stderr_contains: + - "vmstat: extra operand" + exit_code: 1 diff --git a/tests/scenarios/cmd/vmstat/errors/unknown_flag.yaml b/tests/scenarios/cmd/vmstat/errors/unknown_flag.yaml new file mode 100644 index 00000000..c39d8c4d --- /dev/null +++ b/tests/scenarios/cmd/vmstat/errors/unknown_flag.yaml @@ -0,0 +1,9 @@ +description: vmstat exits 1 with an error when given an unknown flag. +skip_assert_against_bash: true +input: + script: |+ + vmstat --no-such-flag +expect: + stdout: "" + stderr_contains: ["vmstat:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/vmstat/flags/active_memory.yaml b/tests/scenarios/cmd/vmstat/flags/active_memory.yaml new file mode 100644 index 00000000..3c6649b6 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/flags/active_memory.yaml @@ -0,0 +1,14 @@ +description: vmstat -a swaps the memory header columns to inact/active instead of buff/cache. +# skip: vmstat is not present in debian:bookworm-slim, and output reflects +# live host state. +skip_assert_against_bash: true +input: + script: |+ + vmstat -a; echo "code:$?" +expect: + stdout_contains: + - "inact" + - "active" + - "code:0" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/vmstat/flags/rejected_flags.yaml b/tests/scenarios/cmd/vmstat/flags/rejected_flags.yaml new file mode 100644 index 00000000..1b892d1a --- /dev/null +++ b/tests/scenarios/cmd/vmstat/flags/rejected_flags.yaml @@ -0,0 +1,22 @@ +description: vmstat rejects flags not implemented in v1 (disk/partition, forks, slab, timestamp, single-header, version) with exit code 1. +# skip: these flags are intentionally not registered; procps vmstat supports +# them but rshell's v1 implementation does not (see the package doc comment). +skip_assert_against_bash: true +input: + script: |+ + for f in -d -p -f -m -t -n -V; do + vmstat "$f" >/dev/null 2>&1 + echo "$f:$?" + done +expect: + stdout: + |+ + -d:1 + -p:1 + -f:1 + -m:1 + -t:1 + -n:1 + -V:1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/vmstat/flags/unit_scale.yaml b/tests/scenarios/cmd/vmstat/flags/unit_scale.yaml new file mode 100644 index 00000000..e9c04f38 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/flags/unit_scale.yaml @@ -0,0 +1,13 @@ +description: vmstat -S M scales memory columns by 2^20 bytes instead of the default 1024. +# skip: vmstat is not present in debian:bookworm-slim, and output reflects +# live host state. +skip_assert_against_bash: true +input: + script: |+ + vmstat -S M > /dev/null; echo "code:$?" + vmstat --unit=m > /dev/null; echo "code:$?" +expect: + stdout_contains: + - "code:0" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/vmstat/flags/wide.yaml b/tests/scenarios/cmd/vmstat/flags/wide.yaml new file mode 100644 index 00000000..fcb5c122 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/flags/wide.yaml @@ -0,0 +1,16 @@ +description: vmstat -w widens the column widths, so the output is longer than the default report. +# skip: vmstat is not present in debian:bookworm-slim, and output reflects +# live host state. +skip_assert_against_bash: true +input: + script: |+ + narrow=$(vmstat | wc -c) + wide=$(vmstat -w | wc -c) + [ "$wide" -gt "$narrow" ] && echo "wider:yes" + echo "code:$?" +expect: + stdout_contains: + - "wider:yes" + - "code:0" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/vmstat/help/help_long.yaml b/tests/scenarios/cmd/vmstat/help/help_long.yaml new file mode 100644 index 00000000..53820a32 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/help/help_long.yaml @@ -0,0 +1,12 @@ +description: vmstat --help prints usage information to stdout and exits with code 0. +# skip: rshell builtin help output differs from the real procps vmstat(8) utility +skip_assert_against_bash: true +input: + script: |+ + vmstat --help +expect: + stdout_contains: + - "Usage: vmstat" + - "--stats" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/vmstat/help/help_short.yaml b/tests/scenarios/cmd/vmstat/help/help_short.yaml new file mode 100644 index 00000000..52c3b774 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/help/help_short.yaml @@ -0,0 +1,11 @@ +description: vmstat -h prints usage information to stdout and exits with code 0. +# skip: rshell builtin help output differs from the real procps vmstat(8) utility +skip_assert_against_bash: true +input: + script: |+ + vmstat -h +expect: + stdout_contains: + - "Usage: vmstat" + stderr: "" + exit_code: 0 From 49bbf62fac250aa7ec120f94eee5d2e61ed8278e Mon Sep 17 00:00:00 2001 From: Erwann Masson Date: Fri, 17 Jul 2026 14:25:26 +0200 Subject: [PATCH 2/4] fix: add skip_windows scenario field and bound vmstat count conversion --- AGENTS.md | 3 ++- analysis/symbols_builtins.go | 2 ++ builtins/vmstat/vmstat.go | 2 +- tests/scenarios/cmd/vmstat/basic/sampling.yaml | 3 +++ tests/scenarios/cmd/vmstat/basic/snapshot.yaml | 3 +++ tests/scenarios/cmd/vmstat/basic/stats.yaml | 3 +++ tests/scenarios/cmd/vmstat/flags/active_memory.yaml | 3 +++ tests/scenarios/cmd/vmstat/flags/unit_scale.yaml | 3 +++ tests/scenarios/cmd/vmstat/flags/wide.yaml | 3 +++ tests/scenarios_test.go | 7 +++++++ 10 files changed, 30 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5c82bdbf..1df22f84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ The shell is supported on Linux, Windows and macOS. The test suite runs all scenarios against `debian:bookworm-slim` (GNU bash + GNU coreutils) and compares output byte-for-byte. Only set `skip_assert_against_bash: true` in a scenario when the behavior intentionally diverges from bash (e.g. sandbox restrictions, blocked commands). - **Prefer scenario tests (`tests/scenarios/`) over Go tests.** Scenario tests are declarative YAML files that are automatically validated against both the shell and bash, making them easier to write, review, and maintain. Only use Go tests when scenario tests cannot express the required behaviour (e.g. testing Go APIs directly, complex programmatic assertions). -- **`ip route show`/`ip route get` happy-path scenario tests cannot be added.** The scenario test framework has no platform-skip mechanism, and `ip route` reads `/proc/net/route` which is Linux-only — the command exits 1 with "not supported" on macOS and Windows. Happy-path coverage lives in `builtins/tests/ip/ip_linux_test.go` instead. +- **`ip route show`/`ip route get` happy-path scenario tests cannot be added.** The scenario test framework's `skip_windows` field only skips Windows, and `ip route` reads `/proc/net/route` which is Linux-only — the command exits 1 with "not supported" on macOS too, so a single scenario still can't express the happy path on every platform. Happy-path coverage lives in `builtins/tests/ip/ip_linux_test.go` instead. - In test scenarios, use `expect.stderr` when possible instead of `stderr_contains`. - Always use the YAML `|+` block scalar for `input.script`, `expect.stdout`, and `expect.stderr` values, even single-line ones. - Test scenarios are asserted against bash by default. Only set `skip_assert_against_bash: true` for features that intentionally diverge from standard bash behavior (e.g. blocked commands, restricted redirects, readonly enforcement). @@ -60,3 +60,4 @@ The shell is supported on Linux, Windows and macOS. - `stdout_windows` / `stderr_windows` — override `stdout` / `stderr` on Windows. - `stdout_contains_windows` / `stderr_contains_windows` — override `stdout_contains` / `stderr_contains` on Windows. - If the Windows field is not set, the non-Windows field is used as fallback. +- Set `skip_windows: true` to skip a scenario entirely on Windows, for commands that are intentionally unsupported there rather than merely producing different output (e.g. `vmstat`, which only reads `/proc` on Linux and `sysctl` on macOS). diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index 2f6260c7..f38e7a1a 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -414,6 +414,7 @@ var builtinPerCommandSymbols = map[string][]string{ "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. + "math.MaxInt", // 🟢 integer constant; bounds the count operand before conversion to int; no side effects. "math.Round", // 🟢 rounds a float64 to the nearest integer; used for CPU-tick percentage rounding; pure function, no I/O. "strconv.ParseUint", // 🟢 string-to-unsigned-int conversion; used to validate delay/count operands; pure function, no I/O. "strings.Join", // 🟢 concatenates a slice of strings with a separator; pure function, no I/O. @@ -781,6 +782,7 @@ var builtinAllowedSymbols = []string{ "math.Inf", // 🟢 returns positive or negative infinity; pure function, no I/O. "math.IsInf", // 🟢 IEEE 754 infinity check; pure function, no I/O. "math.IsNaN", // 🟢 IEEE 754 NaN check; pure function, no I/O. + "math.MaxInt", // 🟢 integer constant; no side effects. "math.MaxInt32", // 🟢 integer constant; no side effects. "math.MaxInt64", // 🟢 integer constant; no side effects. "math.MaxUint64", // 🟢 integer constant; no side effects. diff --git a/builtins/vmstat/vmstat.go b/builtins/vmstat/vmstat.go index 70dddd62..28f9cb6b 100644 --- a/builtins/vmstat/vmstat.go +++ b/builtins/vmstat/vmstat.go @@ -181,7 +181,7 @@ func parseSamplingArgs(args []string) (delay time.Duration, count int, err error } if len(args) == 2 { c, convErr := strconv.ParseUint(args[1], 10, 32) - if convErr != nil || c == 0 { + if convErr != nil || c == 0 || c > uint64(math.MaxInt) { return 0, 0, fmt.Errorf("invalid count '%s'", args[1]) } count = int(c) diff --git a/tests/scenarios/cmd/vmstat/basic/sampling.yaml b/tests/scenarios/cmd/vmstat/basic/sampling.yaml index f53bdc72..a4e096e1 100644 --- a/tests/scenarios/cmd/vmstat/basic/sampling.yaml +++ b/tests/scenarios/cmd/vmstat/basic/sampling.yaml @@ -2,6 +2,9 @@ description: vmstat with a delay and count samples repeatedly, printing exactly # skip: vmstat is not present in debian:bookworm-slim, and output reflects # live host state. skip_assert_against_bash: true +# skip: vmstat is not supported on Windows (see AGENTS.md); this scenario +# requires real output, which only exists on Linux/macOS. +skip_windows: true input: script: |+ vmstat 1 2 | wc -l; echo "code:$?" diff --git a/tests/scenarios/cmd/vmstat/basic/snapshot.yaml b/tests/scenarios/cmd/vmstat/basic/snapshot.yaml index 8467d2d7..3d617749 100644 --- a/tests/scenarios/cmd/vmstat/basic/snapshot.yaml +++ b/tests/scenarios/cmd/vmstat/basic/snapshot.yaml @@ -2,6 +2,9 @@ description: vmstat with no arguments prints a two-line header and one data row. # skip: vmstat is not present in debian:bookworm-slim (it ships in procps, # not installed there), and output reflects live host state. skip_assert_against_bash: true +# skip: vmstat is not supported on Windows (see AGENTS.md); this scenario +# requires real output, which only exists on Linux/macOS. +skip_windows: true input: script: |+ vmstat; echo "code:$?" diff --git a/tests/scenarios/cmd/vmstat/basic/stats.yaml b/tests/scenarios/cmd/vmstat/basic/stats.yaml index b6a91e69..e8228557 100644 --- a/tests/scenarios/cmd/vmstat/basic/stats.yaml +++ b/tests/scenarios/cmd/vmstat/basic/stats.yaml @@ -2,6 +2,9 @@ description: vmstat -s prints a labeled counter summary, one entry per line. # skip: vmstat is not present in debian:bookworm-slim, and counters reflect # live host state. skip_assert_against_bash: true +# skip: vmstat is not supported on Windows (see AGENTS.md); this scenario +# requires real output, which only exists on Linux/macOS. +skip_windows: true input: script: |+ vmstat -s; echo "code:$?" diff --git a/tests/scenarios/cmd/vmstat/flags/active_memory.yaml b/tests/scenarios/cmd/vmstat/flags/active_memory.yaml index 3c6649b6..6ca97af3 100644 --- a/tests/scenarios/cmd/vmstat/flags/active_memory.yaml +++ b/tests/scenarios/cmd/vmstat/flags/active_memory.yaml @@ -2,6 +2,9 @@ description: vmstat -a swaps the memory header columns to inact/active instead o # skip: vmstat is not present in debian:bookworm-slim, and output reflects # live host state. skip_assert_against_bash: true +# skip: vmstat is not supported on Windows (see AGENTS.md); this scenario +# requires real output, which only exists on Linux/macOS. +skip_windows: true input: script: |+ vmstat -a; echo "code:$?" diff --git a/tests/scenarios/cmd/vmstat/flags/unit_scale.yaml b/tests/scenarios/cmd/vmstat/flags/unit_scale.yaml index e9c04f38..8338acbd 100644 --- a/tests/scenarios/cmd/vmstat/flags/unit_scale.yaml +++ b/tests/scenarios/cmd/vmstat/flags/unit_scale.yaml @@ -2,6 +2,9 @@ description: vmstat -S M scales memory columns by 2^20 bytes instead of the defa # skip: vmstat is not present in debian:bookworm-slim, and output reflects # live host state. skip_assert_against_bash: true +# skip: vmstat is not supported on Windows (see AGENTS.md); this scenario +# requires real output, which only exists on Linux/macOS. +skip_windows: true input: script: |+ vmstat -S M > /dev/null; echo "code:$?" diff --git a/tests/scenarios/cmd/vmstat/flags/wide.yaml b/tests/scenarios/cmd/vmstat/flags/wide.yaml index fcb5c122..b0b50c3d 100644 --- a/tests/scenarios/cmd/vmstat/flags/wide.yaml +++ b/tests/scenarios/cmd/vmstat/flags/wide.yaml @@ -2,6 +2,9 @@ description: vmstat -w widens the column widths, so the output is longer than th # skip: vmstat is not present in debian:bookworm-slim, and output reflects # live host state. skip_assert_against_bash: true +# skip: vmstat is not supported on Windows (see AGENTS.md); this scenario +# requires real output, which only exists on Linux/macOS. +skip_windows: true input: script: |+ narrow=$(vmstat | wc -c) diff --git a/tests/scenarios_test.go b/tests/scenarios_test.go index fd915cfc..97477c2a 100644 --- a/tests/scenarios_test.go +++ b/tests/scenarios_test.go @@ -36,6 +36,10 @@ const dockerBashImage = "debian:bookworm-slim" type scenario struct { Description string `yaml:"description"` SkipAssertAgainstBash bool `yaml:"skip_assert_against_bash"` // true = skip bash comparison + // SkipWindows skips the entire scenario on Windows, for commands that are + // intentionally unsupported on that platform (e.g. vmstat) rather than + // merely producing different output. + SkipWindows bool `yaml:"skip_windows"` // Containerized enables container symlink resolution by setting // HostPrefix to the test directory's host/ subdirectory. Containerized bool `yaml:"containerized"` @@ -493,6 +497,9 @@ func TestShellScenarios(t *testing.T) { if sc.Containerized && runtime.GOOS == "windows" { t.Skip("containerized tests are not supported on Windows") } + if sc.SkipWindows && runtime.GOOS == "windows" { + t.Skip("scenario is not supported on Windows") + } t.Parallel() runScenario(t, sc) }) From b23962efc67d0a7a9d0b00529cf1d1e17dd9f526 Mon Sep 17 00:00:00 2001 From: Erwann Masson Date: Fri, 17 Jul 2026 17:27:52 +0200 Subject: [PATCH 3/4] review: split read return and fix overflows --- analysis/symbols_builtins.go | 1 + analysis/symbols_internal.go | 2 + builtins/internal/vmstat/vmstat_linux.go | 89 ++++++++++++------- .../internal/vmstat/vmstat_linux_fuzz_test.go | 2 +- .../vmstat/vmstat_linux_parse_test.go | 18 +++- builtins/vmstat/vmstat.go | 48 +++++++--- 6 files changed, 114 insertions(+), 46 deletions(-) diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index f38e7a1a..d56cf19b 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -415,6 +415,7 @@ var builtinPerCommandSymbols = map[string][]string{ "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. "math.MaxInt", // 🟢 integer constant; bounds the count operand before conversion to int; no side effects. + "math.MaxUint64", // 🟢 integer constant; saturates float-to-uint64 rate conversions instead of relying on out-of-range cast behavior; no side effects. "math.Round", // 🟢 rounds a float64 to the nearest integer; used for CPU-tick percentage rounding; pure function, no I/O. "strconv.ParseUint", // 🟢 string-to-unsigned-int conversion; used to validate delay/count operands; pure function, no I/O. "strings.Join", // 🟢 concatenates a slice of strings with a separator; pure function, no I/O. diff --git a/analysis/symbols_internal.go b/analysis/symbols_internal.go index c0ac2abe..9ee50982 100644 --- a/analysis/symbols_internal.go +++ b/analysis/symbols_internal.go @@ -147,6 +147,7 @@ var internalPerPackageSymbols = map[string][]string{ "errors.New", // 🟢 creates a sentinel error (ErrNotSupported, and a stub-platform error path); pure function, no I/O. "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. "io.EOF", // 🟢 sentinel error value returned by bufio.Scanner at end of file; pure constant. + "math.MaxUint64", // 🟢 integer constant; bounds the /proc/meminfo KiB-to-bytes conversion against overflow; no side effects. "os.Getpagesize", // 🟢 returns the host's memory page size; read-only, no I/O. "os.Open", // 🟠 opens /proc/{stat,meminfo,vmstat,loadavg,uptime} read-only; needed to stream kernel pseudo-files. "path/filepath.Join", // 🟢 joins procPath + file name (e.g. "stat"); pure function, no I/O. @@ -216,6 +217,7 @@ var internalAllowedSymbols = []string{ "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. "io.EOF", // 🟢 vmstat: sentinel error value returned by bufio.Scanner at end of file; pure constant. "io.LimitReader", // 🟢 procsyskernel: wraps a reader with a byte cap; pure wrapper, no I/O by itself. + "math.MaxUint64", // 🟢 vmstat: integer constant; bounds the /proc/meminfo KiB-to-bytes conversion against overflow; no side effects. "io.ReadAll", // 🟠 procsyskernel: reads all data from a bounded reader; used with LimitReader for 4KiB cap. "io.Reader", // 🟢 diskstats: interface type used to feed parseMountInfo from arbitrary readers; pure type, no I/O. "os.Getpagesize", // 🟢 vmstat: returns the host's memory page size; read-only, no I/O. diff --git a/builtins/internal/vmstat/vmstat_linux.go b/builtins/internal/vmstat/vmstat_linux.go index 75ec097c..2b616e82 100644 --- a/builtins/internal/vmstat/vmstat_linux.go +++ b/builtins/internal/vmstat/vmstat_linux.go @@ -12,6 +12,7 @@ import ( "context" "fmt" "io" + "math" "os" "path/filepath" "strconv" @@ -54,11 +55,24 @@ func readImpl(ctx context.Context, procPath string) (Stats, error) { st.ClockTicksPerSec = clockTicksPerSec st.PageSize = uint64(os.Getpagesize()) - if err := readProcStat(ctx, filepath.Join(procPath, "stat"), &st); err == nil { - st.Partial |= FieldProcs | FieldSystem | FieldCPU + if foundProcs, foundSystem, foundCPU, err := readProcStat(ctx, filepath.Join(procPath, "stat"), &st); err == nil { + if foundProcs { + st.Partial |= FieldProcs + } + if foundSystem { + st.Partial |= FieldSystem + } + if foundCPU { + st.Partial |= FieldCPU + } } - if err := readProcMeminfo(ctx, filepath.Join(procPath, "meminfo"), &st); err == nil { - st.Partial |= FieldMemory | FieldSwap + if foundMemory, foundSwap, err := readProcMeminfo(ctx, filepath.Join(procPath, "meminfo"), &st); err == nil { + if foundMemory { + st.Partial |= FieldMemory + } + if foundSwap { + st.Partial |= FieldSwap + } } if err := readProcVmstat(ctx, filepath.Join(procPath, "vmstat"), &st); err == nil { st.Partial |= FieldPaging @@ -108,10 +122,12 @@ func scanBounded(ctx context.Context, path string, fn func(line string)) error { } // readProcStat parses procs_running, procs_blocked, intr, ctxt, and the -// aggregate "cpu " line from /proc/stat. -func readProcStat(ctx context.Context, path string, st *Stats) error { - found := false - err := scanBounded(ctx, path, func(line string) { +// aggregate "cpu " line from /proc/stat. It reports foundProcs/foundSystem/ +// foundCPU independently so readImpl never sets a Partial bit for a field +// group whose source line was actually absent (e.g. a /proc/stat with +// "intr " but no "cpu " line must not mark CPU data as available). +func readProcStat(ctx context.Context, path string, st *Stats) (foundProcs, foundSystem, foundCPU bool, err error) { + err = scanBounded(ctx, path, func(line string) { switch { case strings.HasPrefix(line, "cpu "): fields := strings.Fields(line) @@ -119,66 +135,71 @@ func readProcStat(ctx context.Context, path string, st *Stats) error { vals := parseUint64Fields(fields[1:], 8) st.CPUUser, st.CPUNice, st.CPUSystem, st.CPUIdle = vals[0], vals[1], vals[2], vals[3] st.CPUIOWait, st.CPUIRQ, st.CPUSoftIRQ, st.CPUSteal = vals[4], vals[5], vals[6], vals[7] - found = true + foundCPU = true case strings.HasPrefix(line, "intr "): st.Interrupts = parseUint64Field(line) - found = true + foundSystem = true case strings.HasPrefix(line, "ctxt "): st.ContextSwitches = parseUint64Field(line) - found = true + foundSystem = true case strings.HasPrefix(line, "procs_running "): st.ProcsRunning = parseUint64Field(line) - found = true + foundProcs = true case strings.HasPrefix(line, "procs_blocked "): st.ProcsBlocked = parseUint64Field(line) - found = true + foundProcs = true } }) if err != nil { - return err + return false, false, false, err } - if !found { - return fmt.Errorf("vmstat: no recognised fields in %s", path) + if !foundProcs && !foundSystem && !foundCPU { + return false, false, false, fmt.Errorf("vmstat: no recognised fields in %s", path) } - return nil + return foundProcs, foundSystem, foundCPU, nil } +// maxMeminfoKB is the largest KiB value that can be multiplied by 1024 +// without overflowing uint64; it bounds the KiB-to-bytes conversion below. +const maxMeminfoKB = math.MaxUint64 / 1024 + // readProcMeminfo parses the memory and swap fields from /proc/meminfo. -// Values in the file are in KiB; they are converted to bytes. -func readProcMeminfo(ctx context.Context, path string, st *Stats) error { - found := false - err := scanBounded(ctx, path, func(line string) { +// Values in the file are in KiB; they are converted to bytes. It reports +// foundMemory/foundSwap independently so readImpl never sets a Partial bit +// for a field group whose lines were actually absent from the file. +func readProcMeminfo(ctx context.Context, path string, st *Stats) (foundMemory, foundSwap bool, err error) { + err = scanBounded(ctx, path, func(line string) { key, kb, ok := parseMeminfoLine(line) - if !ok { + if !ok || kb > maxMeminfoKB { return } bytes := kb * 1024 switch key { case "MemTotal": - st.MemTotal, found = bytes, true + st.MemTotal, foundMemory = bytes, true case "MemFree": - st.MemFree, found = bytes, true + st.MemFree, foundMemory = bytes, true case "Buffers": - st.MemBuffers, found = bytes, true + st.MemBuffers, foundMemory = bytes, true case "Cached": - st.MemCached, found = bytes, true + st.MemCached, foundMemory = bytes, true case "Active": - st.MemActive, found = bytes, true + st.MemActive, foundMemory = bytes, true case "Inactive": - st.MemInactive, found = bytes, true + st.MemInactive, foundMemory = bytes, true case "SwapTotal": - st.SwapTotal, found = bytes, true + st.SwapTotal, foundSwap = bytes, true case "SwapFree": - st.SwapFree, found = bytes, true + st.SwapFree, foundSwap = bytes, true } }) if err != nil { - return err + return false, false, err } - if !found { - return fmt.Errorf("vmstat: no recognised fields in %s", path) + if !foundMemory && !foundSwap { + return false, false, fmt.Errorf("vmstat: no recognised fields in %s", path) } - return nil + return foundMemory, foundSwap, nil } // parseMeminfoLine splits a "Key: 123 kB" line into its key and diff --git a/builtins/internal/vmstat/vmstat_linux_fuzz_test.go b/builtins/internal/vmstat/vmstat_linux_fuzz_test.go index 3604011d..324bc02a 100644 --- a/builtins/internal/vmstat/vmstat_linux_fuzz_test.go +++ b/builtins/internal/vmstat/vmstat_linux_fuzz_test.go @@ -84,6 +84,6 @@ func FuzzReadProcStat(f *testing.F) { t.Skip() } var st Stats - _ = readProcStat(context.Background(), path, &st) // MUST NOT panic + _, _, _, _ = readProcStat(context.Background(), path, &st) // MUST NOT panic }) } diff --git a/builtins/internal/vmstat/vmstat_linux_parse_test.go b/builtins/internal/vmstat/vmstat_linux_parse_test.go index 0498998f..4b069828 100644 --- a/builtins/internal/vmstat/vmstat_linux_parse_test.go +++ b/builtins/internal/vmstat/vmstat_linux_parse_test.go @@ -138,12 +138,28 @@ func TestReadProcStat_MalformedCPULine(t *testing.T) { writeTemp(t, dir, "stat", "cpu 1 2\nprocs_running 3\n") var st Stats - err := readProcStat(context.Background(), filepath.Join(dir, "stat"), &st) + foundProcs, foundSystem, foundCPU, err := readProcStat(context.Background(), filepath.Join(dir, "stat"), &st) require.NoError(t, err) assert.EqualValues(t, 1, st.CPUUser) assert.EqualValues(t, 2, st.CPUNice) assert.EqualValues(t, 0, st.CPUSystem, "missing trailing fields default to 0, not a panic") assert.EqualValues(t, 3, st.ProcsRunning) + assert.True(t, foundProcs) + assert.True(t, foundCPU) + assert.False(t, foundSystem, "no intr/ctxt line means the system group was not actually read") +} + +func TestReadImpl_PartialGroupsAreIndependentPerLine(t *testing.T) { + dir := t.TempDir() + // /proc/stat has only an "intr " line: no "cpu " line, no procs_* + // lines. FieldCPU/FieldProcs must NOT be set, or the CPU/procs + // columns would render a fabricated "0" instead of "-". + writeTemp(t, dir, "stat", "intr 42\n") + + st, err := readImpl(context.Background(), dir) + require.NoError(t, err) + assert.Equal(t, FieldSystem, st.Partial) + assert.EqualValues(t, 42, st.Interrupts) } func TestParseMeminfoLine(t *testing.T) { diff --git a/builtins/vmstat/vmstat.go b/builtins/vmstat/vmstat.go index 28f9cb6b..4606d026 100644 --- a/builtins/vmstat/vmstat.go +++ b/builtins/vmstat/vmstat.go @@ -282,6 +282,8 @@ func runStats(ctx context.Context, callCtx *builtins.CallContext, divisor int64) line(hasCPU, st.CPUSystem, "CPU system ticks") line(hasCPU, st.CPUIdle, "CPU idle ticks") line(hasCPU, st.CPUIOWait, "CPU I/O-wait ticks") + line(hasCPU, st.CPUIRQ, "CPU IRQ ticks") + line(hasCPU, st.CPUSoftIRQ, "CPU softirq ticks") line(hasCPU, st.CPUSteal, "CPU steal ticks") lineFloat(hasLoad, st.LoadAvg1, "1 minute load average") lineFloat(hasLoad, st.LoadAvg5, "5 minute load average") @@ -368,18 +370,28 @@ func formatRow(cur ivmstat.Stats, prev *ivmstat.Stats, elapsedSeconds float64, d cells = append(cells, dash(w), dash(w)) } - // memory: instantaneous sizes, not rates. + // memory: instantaneous sizes, not rates. swpd is backed by the swap + // field group while the remaining three columns are backed by the + // memory field group; on macOS the two sysctls can succeed/fail + // independently, so they are gated separately rather than as one OR'd + // check (which would render a fabricated "0" for whichever group is + // actually missing). w = groups[1].width - if cur.Partial&(ivmstat.FieldMemory|ivmstat.FieldSwap) != 0 { + if cur.Partial&ivmstat.FieldSwap != 0 { swpd := subClamp(cur.SwapTotal, cur.SwapFree) - cells = append(cells, fmtScaled(swpd, divisor, w), fmtScaled(cur.MemFree, divisor, w)) + cells = append(cells, fmtScaled(swpd, divisor, w)) + } else { + cells = append(cells, dash(w)) + } + if cur.Partial&ivmstat.FieldMemory != 0 { + cells = append(cells, fmtScaled(cur.MemFree, divisor, w)) if active { cells = append(cells, fmtScaled(cur.MemInactive, divisor, w), fmtScaled(cur.MemActive, divisor, w)) } else { cells = append(cells, fmtScaled(cur.MemBuffers, divisor, w), fmtScaled(cur.MemCached, divisor, w)) } } else { - cells = append(cells, dash(w), dash(w), dash(w), dash(w)) + cells = append(cells, dash(w), dash(w), dash(w)) } // swap: si/so, in KB/sec. @@ -446,6 +458,20 @@ func subClamp(a, b uint64) uint64 { return 0 } +// satUint64 converts a non-negative float64 rate to uint64, saturating to +// math.MaxUint64 instead of relying on Go's implementation-defined +// out-of-range float-to-integer conversion (e.g. a huge counter delta over +// a tiny elapsedSeconds). +func satUint64(f float64) uint64 { + if f <= 0 { + return 0 + } + if f >= math.MaxUint64 { + return math.MaxUint64 + } + return uint64(f) +} + // rateSwap computes si/so (KB/sec swapped in/out) either since boot // (prev == nil) or as a delta between two samples. func rateSwap(cur ivmstat.Stats, prev *ivmstat.Stats, elapsedSeconds float64) (si, so uint64) { @@ -458,8 +484,10 @@ func rateSwap(cur ivmstat.Stats, prev *ivmstat.Stats, elapsedSeconds float64) (s if kb == 0 { kb = 1 } - si = uint64(float64(inPages*kb) / elapsedSeconds) - so = uint64(float64(outPages*kb) / elapsedSeconds) + // The page-count-to-KB multiplication happens in float64 space (not + // uint64) so a huge counter delta cannot wrap before the division. + si = satUint64(float64(inPages) * float64(kb) / elapsedSeconds) + so = satUint64(float64(outPages) * float64(kb) / elapsedSeconds) return si, so } @@ -470,8 +498,8 @@ func rateIO(cur ivmstat.Stats, prev *ivmstat.Stats, elapsedSeconds float64) (bi, inKB = subClamp(cur.PagesInKB, prev.PagesInKB) outKB = subClamp(cur.PagesOutKB, prev.PagesOutKB) } - bi = uint64(float64(inKB) / elapsedSeconds) - bo = uint64(float64(outKB) / elapsedSeconds) + bi = satUint64(float64(inKB) / elapsedSeconds) + bo = satUint64(float64(outKB) / elapsedSeconds) return bi, bo } @@ -482,8 +510,8 @@ func rateSystem(cur ivmstat.Stats, prev *ivmstat.Stats, elapsedSeconds float64) intr = subClamp(cur.Interrupts, prev.Interrupts) ctxt = subClamp(cur.ContextSwitches, prev.ContextSwitches) } - in = uint64(float64(intr) / elapsedSeconds) - cs = uint64(float64(ctxt) / elapsedSeconds) + in = satUint64(float64(intr) / elapsedSeconds) + cs = satUint64(float64(ctxt) / elapsedSeconds) return in, cs } From f5003dfb5c5bb8ca0f37098a3bbce57120b4f524 Mon Sep 17 00:00:00 2001 From: Erwann Masson Date: Wed, 22 Jul 2026 14:11:53 +0200 Subject: [PATCH 4/4] fix: address vmstat PR review findings (stats operand handling, unit labels, macOS memory detail) --- builtins/internal/vmstat/vmstat.go | 13 +++-- .../internal/vmstat/vmstat_darwin_test.go | 1 + builtins/internal/vmstat/vmstat_linux.go | 31 +++++++----- .../vmstat/vmstat_linux_parse_test.go | 2 +- builtins/tests/vmstat/vmstat_linux_test.go | 13 +++-- builtins/vmstat/vmstat.go | 49 +++++++++++-------- .../vmstat/basic/stats_ignores_operand.yaml | 17 +++++++ .../vmstat/errors/stats_extra_operand.yaml | 10 ---- .../vmstat/errors/stats_invalid_operand.yaml | 10 ++++ .../cmd/vmstat/flags/stats_unit_label.yaml | 16 ++++++ 10 files changed, 111 insertions(+), 51 deletions(-) create mode 100644 tests/scenarios/cmd/vmstat/basic/stats_ignores_operand.yaml delete mode 100644 tests/scenarios/cmd/vmstat/errors/stats_extra_operand.yaml create mode 100644 tests/scenarios/cmd/vmstat/errors/stats_invalid_operand.yaml create mode 100644 tests/scenarios/cmd/vmstat/flags/stats_unit_label.yaml diff --git a/builtins/internal/vmstat/vmstat.go b/builtins/internal/vmstat/vmstat.go index f09e5add..6c5ac7a5 100644 --- a/builtins/internal/vmstat/vmstat.go +++ b/builtins/internal/vmstat/vmstat.go @@ -54,8 +54,7 @@ type Fields uint32 const ( // FieldProcs covers ProcsRunning / ProcsBlocked. FieldProcs Fields = 1 << iota - // FieldMemory covers MemTotal / MemFree / MemBuffers / MemCached / - // MemActive / MemInactive. + // FieldMemory covers MemTotal only. FieldMemory // FieldSwap covers SwapTotal / SwapFree. FieldSwap @@ -68,10 +67,18 @@ const ( FieldCPU // FieldLoadAvg covers LoadAvg1 / LoadAvg5 / LoadAvg15. FieldLoadAvg + // FieldMemoryDetail covers MemFree / MemBuffers / MemCached / MemActive + // / MemInactive. Split out from FieldMemory because macOS populates + // MemTotal via hw.memsize but has no sysctl for the breakdown without a + // Mach host_statistics64 call (see package doc): a caller that gated + // derived values like "used memory" (MemTotal - MemFree) on FieldMemory + // alone would silently treat the zeroed MemFree as real, reporting 100% + // memory used on every Mac. + FieldMemoryDetail ) // AllFields is every field group. The Linux backend sets this. -const AllFields = FieldProcs | FieldMemory | FieldSwap | FieldPaging | FieldSystem | FieldCPU | FieldLoadAvg +const AllFields = FieldProcs | FieldMemory | FieldSwap | FieldPaging | FieldSystem | FieldCPU | FieldLoadAvg | FieldMemoryDetail // Stats holds host-pressure counters. Unless noted, values are cumulative // counters since boot (matching /proc/*'s semantics); the vmstat builtin is diff --git a/builtins/internal/vmstat/vmstat_darwin_test.go b/builtins/internal/vmstat/vmstat_darwin_test.go index b5b24462..9f71bba1 100644 --- a/builtins/internal/vmstat/vmstat_darwin_test.go +++ b/builtins/internal/vmstat/vmstat_darwin_test.go @@ -32,6 +32,7 @@ func TestReadImpl_Darwin_HappyPath(t *testing.T) { assert.Zero(t, st.Partial&FieldPaging) assert.Zero(t, st.Partial&FieldSystem) assert.Zero(t, st.Partial&FieldCPU) + assert.Zero(t, st.Partial&FieldMemoryDetail, "macOS has no sysctl for the free/buffers/cached/active/inactive breakdown without Mach host_statistics64") } func TestReadU32LE(t *testing.T) { diff --git a/builtins/internal/vmstat/vmstat_linux.go b/builtins/internal/vmstat/vmstat_linux.go index 2b616e82..0f400f6a 100644 --- a/builtins/internal/vmstat/vmstat_linux.go +++ b/builtins/internal/vmstat/vmstat_linux.go @@ -66,10 +66,13 @@ func readImpl(ctx context.Context, procPath string) (Stats, error) { st.Partial |= FieldCPU } } - if foundMemory, foundSwap, err := readProcMeminfo(ctx, filepath.Join(procPath, "meminfo"), &st); err == nil { + if foundMemory, foundMemoryDetail, foundSwap, err := readProcMeminfo(ctx, filepath.Join(procPath, "meminfo"), &st); err == nil { if foundMemory { st.Partial |= FieldMemory } + if foundMemoryDetail { + st.Partial |= FieldMemoryDetail + } if foundSwap { st.Partial |= FieldSwap } @@ -165,9 +168,11 @@ const maxMeminfoKB = math.MaxUint64 / 1024 // readProcMeminfo parses the memory and swap fields from /proc/meminfo. // Values in the file are in KiB; they are converted to bytes. It reports -// foundMemory/foundSwap independently so readImpl never sets a Partial bit -// for a field group whose lines were actually absent from the file. -func readProcMeminfo(ctx context.Context, path string, st *Stats) (foundMemory, foundSwap bool, err error) { +// foundMemory (MemTotal only), foundMemoryDetail (the free/buffers/cached/ +// active/inactive breakdown), and foundSwap independently so readImpl never +// sets a Partial bit for a field group whose lines were actually absent +// from the file. +func readProcMeminfo(ctx context.Context, path string, st *Stats) (foundMemory, foundMemoryDetail, foundSwap bool, err error) { err = scanBounded(ctx, path, func(line string) { key, kb, ok := parseMeminfoLine(line) if !ok || kb > maxMeminfoKB { @@ -178,15 +183,15 @@ func readProcMeminfo(ctx context.Context, path string, st *Stats) (foundMemory, case "MemTotal": st.MemTotal, foundMemory = bytes, true case "MemFree": - st.MemFree, foundMemory = bytes, true + st.MemFree, foundMemoryDetail = bytes, true case "Buffers": - st.MemBuffers, foundMemory = bytes, true + st.MemBuffers, foundMemoryDetail = bytes, true case "Cached": - st.MemCached, foundMemory = bytes, true + st.MemCached, foundMemoryDetail = bytes, true case "Active": - st.MemActive, foundMemory = bytes, true + st.MemActive, foundMemoryDetail = bytes, true case "Inactive": - st.MemInactive, foundMemory = bytes, true + st.MemInactive, foundMemoryDetail = bytes, true case "SwapTotal": st.SwapTotal, foundSwap = bytes, true case "SwapFree": @@ -194,12 +199,12 @@ func readProcMeminfo(ctx context.Context, path string, st *Stats) (foundMemory, } }) if err != nil { - return false, false, err + return false, false, false, err } - if !foundMemory && !foundSwap { - return false, false, fmt.Errorf("vmstat: no recognised fields in %s", path) + if !foundMemory && !foundMemoryDetail && !foundSwap { + return false, false, false, fmt.Errorf("vmstat: no recognised fields in %s", path) } - return foundMemory, foundSwap, nil + return foundMemory, foundMemoryDetail, foundSwap, nil } // parseMeminfoLine splits a "Key: 123 kB" line into its key and diff --git a/builtins/internal/vmstat/vmstat_linux_parse_test.go b/builtins/internal/vmstat/vmstat_linux_parse_test.go index 4b069828..ed318075 100644 --- a/builtins/internal/vmstat/vmstat_linux_parse_test.go +++ b/builtins/internal/vmstat/vmstat_linux_parse_test.go @@ -113,7 +113,7 @@ func TestReadImpl_PartialWhenFilesMissing(t *testing.T) { st, err := readImpl(context.Background(), dir) require.NoError(t, err) - assert.Equal(t, FieldMemory|FieldSwap, st.Partial) + assert.Equal(t, FieldMemory|FieldMemoryDetail|FieldSwap, st.Partial) assert.EqualValues(t, 0, st.ProcsRunning, "no /proc/stat means procs fields stay zero") } diff --git a/builtins/tests/vmstat/vmstat_linux_test.go b/builtins/tests/vmstat/vmstat_linux_test.go index 741ccff0..9ac2be29 100644 --- a/builtins/tests/vmstat/vmstat_linux_test.go +++ b/builtins/tests/vmstat/vmstat_linux_test.go @@ -151,11 +151,18 @@ func TestVmstatStatsFlag(t *testing.T) { assert.Contains(t, stdout, "minute load average") } -func TestVmstatStatsRejectsExtraOperand(t *testing.T) { +func TestVmstatStatsIgnoresOperand(t *testing.T) { writeSyntheticProc(t) - _, stderr, code := cmdRun(t, "vmstat -s 1") + stdout, stderr, code := cmdRun(t, "vmstat -s 1") + require.Equal(t, 0, code, "stderr: %s", stderr) + assert.Contains(t, stdout, "total memory") +} + +func TestVmstatStatsRejectsInvalidOperand(t *testing.T) { + writeSyntheticProc(t) + _, stderr, code := cmdRun(t, "vmstat -s notanumber") assert.Equal(t, 1, code) - assert.Contains(t, stderr, "extra operand") + assert.Contains(t, stderr, "invalid delay") } func TestVmstatSamplingWithCount(t *testing.T) { diff --git a/builtins/vmstat/vmstat.go b/builtins/vmstat/vmstat.go index 4606d026..b175b0ac 100644 --- a/builtins/vmstat/vmstat.go +++ b/builtins/vmstat/vmstat.go @@ -132,19 +132,21 @@ func makeFlags(fs *builtins.FlagSet) builtins.HandlerFunc { return builtins.Result{Code: 1} } - if *f.stats { - if len(args) > 0 { - callCtx.Errf("vmstat: extra operand '%s'\n", args[0]) - return builtins.Result{Code: 1} - } - return runStats(ctx, callCtx, divisor) - } - + // procps vmstat still parses [delay [count]] with -s/--stats (see + // `vmstat --help`'s "vmstat [options] [delay [count]]" grammar and + // `vmstat -s 1`, which exits 0), but the stats report ignores them + // entirely — it never samples more than once. Validate the operands + // for shape (so a malformed operand is still rejected) without + // using the resulting delay/count when -s is set. delay, count, err := parseSamplingArgs(args) if err != nil { callCtx.Errf("vmstat: %v\n", err) return builtins.Result{Code: 1} } + + if *f.stats { + return runStats(ctx, callCtx, divisor, *f.unit) + } return runSampling(ctx, callCtx, *f.active, *f.wide, divisor, delay, count) } } @@ -227,8 +229,11 @@ func runSampling(ctx context.Context, callCtx *builtins.CallContext, active, wid return builtins.Result{} } -// runStats implements -s/--stats: one labeled counter per line. -func runStats(ctx context.Context, callCtx *builtins.CallContext, divisor int64) builtins.Result { +// runStats implements -s/--stats: one labeled counter per line. unit is the +// raw -S/--unit argument (k|K|m|M) and labels the memory/swap lines that are +// scaled by divisor, matching procps vmstat's behavior of relabeling those +// lines rather than leaving a stale "K" when a different unit is selected. +func runStats(ctx context.Context, callCtx *builtins.CallContext, divisor int64, unit string) builtins.Result { st, err := ivmstat.Read(ctx, ProcPath) if err != nil { callCtx.Errf("vmstat: %v\n", err) @@ -236,6 +241,8 @@ func runStats(ctx context.Context, callCtx *builtins.CallContext, divisor int64) } hasMem := st.Partial&ivmstat.FieldMemory != 0 + hasMemDetail := st.Partial&ivmstat.FieldMemoryDetail != 0 + hasUsedMem := hasMem && hasMemDetail hasSwap := st.Partial&ivmstat.FieldSwap != 0 hasProcs := st.Partial&ivmstat.FieldProcs != 0 hasPaging := st.Partial&ivmstat.FieldPaging != 0 @@ -259,16 +266,16 @@ func runStats(ctx context.Context, callCtx *builtins.CallContext, divisor int64) callCtx.Outf("%12.2f %s\n", v, label) } - line(hasMem, st.MemTotal/u, "K total memory") - line(hasMem, subClamp(st.MemTotal, st.MemFree)/u, "K used memory") - line(hasMem, st.MemActive/u, "K active memory") - line(hasMem, st.MemInactive/u, "K inactive memory") - line(hasMem, st.MemFree/u, "K free memory") - line(hasMem, st.MemBuffers/u, "K buffer memory") - line(hasMem, st.MemCached/u, "K swap cache") - line(hasSwap, st.SwapTotal/u, "K total swap") - line(hasSwap, subClamp(st.SwapTotal, st.SwapFree)/u, "K used swap") - line(hasSwap, st.SwapFree/u, "K free swap") + line(hasMem, st.MemTotal/u, unit+" total memory") + line(hasUsedMem, subClamp(st.MemTotal, st.MemFree)/u, unit+" used memory") + line(hasMemDetail, st.MemActive/u, unit+" active memory") + line(hasMemDetail, st.MemInactive/u, unit+" inactive memory") + line(hasMemDetail, st.MemFree/u, unit+" free memory") + line(hasMemDetail, st.MemBuffers/u, unit+" buffer memory") + line(hasMemDetail, st.MemCached/u, unit+" swap cache") + line(hasSwap, st.SwapTotal/u, unit+" total swap") + line(hasSwap, subClamp(st.SwapTotal, st.SwapFree)/u, unit+" used swap") + line(hasSwap, st.SwapFree/u, unit+" free swap") line(hasProcs, st.ProcsRunning, "runnable processes") line(hasProcs, st.ProcsBlocked, "processes blocked waiting for I/O") line(hasPaging, st.PagesInKB, "K paged in from disk") @@ -383,7 +390,7 @@ func formatRow(cur ivmstat.Stats, prev *ivmstat.Stats, elapsedSeconds float64, d } else { cells = append(cells, dash(w)) } - if cur.Partial&ivmstat.FieldMemory != 0 { + if cur.Partial&ivmstat.FieldMemoryDetail != 0 { cells = append(cells, fmtScaled(cur.MemFree, divisor, w)) if active { cells = append(cells, fmtScaled(cur.MemInactive, divisor, w), fmtScaled(cur.MemActive, divisor, w)) diff --git a/tests/scenarios/cmd/vmstat/basic/stats_ignores_operand.yaml b/tests/scenarios/cmd/vmstat/basic/stats_ignores_operand.yaml new file mode 100644 index 00000000..475b084a --- /dev/null +++ b/tests/scenarios/cmd/vmstat/basic/stats_ignores_operand.yaml @@ -0,0 +1,17 @@ +description: vmstat -s ignores a trailing delay/count operand (procps still parses [delay [count]] with -s, but the stats report never samples more than once). +# skip: vmstat is not present in debian:bookworm-slim, and counters reflect +# live host state. +skip_assert_against_bash: true +# skip: vmstat is not supported on Windows (see AGENTS.md); this scenario +# requires real output, which only exists on Linux/macOS. +skip_windows: true +input: + script: |+ + vmstat -s 1; echo "code:$?" +expect: + stdout_contains: + - "total memory" + - "total swap" + - "code:0" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/vmstat/errors/stats_extra_operand.yaml b/tests/scenarios/cmd/vmstat/errors/stats_extra_operand.yaml deleted file mode 100644 index 72155d30..00000000 --- a/tests/scenarios/cmd/vmstat/errors/stats_extra_operand.yaml +++ /dev/null @@ -1,10 +0,0 @@ -description: vmstat -s rejects a positional operand (delay/count are meaningless with -s/--stats). -skip_assert_against_bash: true -input: - script: |+ - vmstat -s 1 -expect: - stdout: "" - stderr_contains: - - "vmstat: extra operand" - exit_code: 1 diff --git a/tests/scenarios/cmd/vmstat/errors/stats_invalid_operand.yaml b/tests/scenarios/cmd/vmstat/errors/stats_invalid_operand.yaml new file mode 100644 index 00000000..f91c9ae8 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/errors/stats_invalid_operand.yaml @@ -0,0 +1,10 @@ +description: vmstat -s still rejects a malformed delay operand even though a well-formed one is ignored. +skip_assert_against_bash: true +input: + script: |+ + vmstat -s notanumber +expect: + stdout: "" + stderr_contains: + - "vmstat: invalid delay" + exit_code: 1 diff --git a/tests/scenarios/cmd/vmstat/flags/stats_unit_label.yaml b/tests/scenarios/cmd/vmstat/flags/stats_unit_label.yaml new file mode 100644 index 00000000..2afa10da --- /dev/null +++ b/tests/scenarios/cmd/vmstat/flags/stats_unit_label.yaml @@ -0,0 +1,16 @@ +description: vmstat -s -S M relabels the scaled memory/swap lines with the selected unit instead of a stale "K". +# skip: vmstat is not present in debian:bookworm-slim, and output reflects +# live host state. +skip_assert_against_bash: true +# skip: vmstat is not supported on Windows (see AGENTS.md); this scenario +# requires real output, which only exists on Linux/macOS. +skip_windows: true +input: + script: |+ + vmstat -s -S M +expect: + stdout_contains: + - "M total memory" + - "M total swap" + stderr: "" + exit_code: 0