diff --git a/AGENTS.md b/AGENTS.md index b4d3736b..628f5307 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,9 @@ 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. -- **`free` bypasses `AllowedPaths` for its Linux-only memory read, and is intentionally not implemented on macOS/Windows.** `free` delegates to `builtins/internal/meminfo`, which on Linux reads `/proc/meminfo` directly via `os.Open` — the same documented exception as `ss`/`ip route`/`df`: the path is hardcoded, never derived from user input, and only aggregate host-wide memory/swap counters are exposed (no per-process or environment data). On macOS and Windows, `meminfo.Read` returns `ErrNotSupported` and `free` exits 1 rather than shipping a partial implementation: macOS has no sysctl(3)-level equivalent of the buffers/cache/shared breakdown (only the Mach `host_statistics64` RPC exposes it, which needs cgo or dynamic libSystem symbol resolution that no other builtin in this repo uses), and Windows' `GlobalMemoryStatusEx` has no concept of buffers/cache/shared at all — reporting those columns as a literal `0` would read as "this host has none" rather than "not available on this platform," which could mislead an agent's memory-pressure diagnosis. `free` is also scoped to a single-shot snapshot (`free`, `free -h`) per the accepted-candidate design brief; it does not implement `-s`/`-c` repeated sampling — that is out of scope for `free` and belongs to the planned `vmstat` builtin. +- **`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. + +- **`free` bypasses `AllowedPaths` for its Linux-only memory read, and is intentionally not implemented on macOS/Windows.** `free` delegates to `builtins/internal/meminfo`, which on Linux reads `/proc/meminfo` directly via `os.Open` — the same documented exception as `ss`/`ip route`/`df`: the path is hardcoded, never derived from user input, and only aggregate host-wide memory/swap counters are exposed (no per-process or environment data). On macOS and Windows, `meminfo.Read` returns `ErrNotSupported` and `free` exits 1 rather than shipping a partial implementation: macOS has no sysctl(3)-level equivalent of the buffers/cache/shared breakdown (only the Mach `host_statistics64` RPC exposes it, which needs cgo or dynamic libSystem symbol resolution that no other builtin in this repo uses), and Windows' `GlobalMemoryStatusEx` has no concept of buffers/cache/shared at all — reporting those columns as a literal `0` would read as "this host has none" rather than "not available on this platform," which could mislead an agent's memory-pressure diagnosis. `free` is also scoped to a single-shot snapshot (`free`, `free -h`) per the accepted-candidate design brief; it does not implement `-s`/`-c` repeated sampling — that is out of scope for `free` and belongs to the `vmstat` builtin. - **`journalctl` bypasses `AllowedPaths` for trusted systemd target access.** The builtin delegates journal discovery and reads, disk-usage scans, vacuuming, machine-ID reads, and journald control-socket access to `internal/systemd`, which opens the configured paths directly with `os` APIs. `SystemdTargetConfig.JournalDirs`, `SystemdTargetConfig.MachineIDPath`, and `SystemdTargetConfig.JournalControlSocket` are fixed by the embedding application when constructing the runner and cannot be supplied or changed by shell scripts. Operators therefore cannot use `AllowedPaths` to block these accesses; authorization is enforced separately by `AllowedCommands`, exact `AllowedSystemServices` grants, and remediation mode for mutations. All configured target paths are trusted and must refer to the same host. See the trusted systemd target exception in `docs/RULES.md` for the backend requirements. @@ -54,8 +56,8 @@ 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. -- **`free` happy-path scenario tests cannot be added, for the same reason as `ip route`.** `free` is Linux-only; the same script exits 0 with real memory data on Linux but exits 1 with "not supported" elsewhere, and the scenario framework cannot express a platform-conditional expectation. Happy-path coverage lives in `builtins/internal/meminfo`'s `parseMeminfo` tests (fixed fixtures, run on Linux CI) plus `TestFreeLinuxHappyPath`/`TestFreeNotSupportedOffLinux` in `builtins/free/free_test.go` (both `runtime.GOOS`-gated). Only `free`'s help/error paths, which behave identically on every platform, have scenario coverage. +- **`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. +- **`free` happy-path scenario tests cannot be added, for the same reason as `ip route`.** `free` is Linux-only; the same script exits 0 with real memory data on Linux but exits 1 with "not supported" elsewhere, and `skip_windows` only skips Windows, not macOS, so the scenario framework still cannot express a platform-conditional expectation. Happy-path coverage lives in `builtins/internal/meminfo`'s `parseMeminfo` tests (fixed fixtures, run on Linux CI) plus `TestFreeLinuxHappyPath`/`TestFreeNotSupportedOffLinux` in `builtins/free/free_test.go` (both `runtime.GOOS`-gated). Only `free`'s help/error paths, which behave identically on every platform, have scenario coverage. - 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). @@ -63,3 +65,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/README.md b/README.md index 15df6fda..bfa40960 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Vacuum thresholds are provided by the `journalctl` command itself. The backend r - **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`, `df`, and `free` 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; `free` reads `/proc/meminfo` (Linux only) directly via `os.Open`. These paths are hardcoded — never derived from user input — and the reads return metadata only (block / inode counts, filesystem type, block size, or aggregate memory/swap counters). 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 `free` from reporting host memory usage — these reads succeed regardless of the configured path policy. +> **Note:** The `ss`, `ip route`, `df`, `vmstat`, and `free` 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`); `free` reads `/proc/meminfo` (Linux only) directly via `os.Open`. 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, `vmstat` from reporting memory/CPU/IO pressure counters, or `free` from reporting host memory usage — 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 5844656d..40ade23a 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -44,6 +44,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 4c1dad33..a4d06de1 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -439,6 +439,23 @@ 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.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. + "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. @@ -616,6 +633,7 @@ var builtinPerCommandCallContextFields = map[string][]string{ "ss": {}, "true": {}, "uname": {}, + "vmstat": {}, "cat": { "OpenFile", @@ -802,11 +820,13 @@ 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. "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. @@ -883,6 +903,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.Parse", // 🟢 parses timestamps according to a caller-supplied layout; pure function, no I/O. "time.ParseDuration", // 🟢 parses Go duration strings (e.g. "1s"); pure function, no I/O. "time.ParseInLocation", // 🟢 parses timestamps in a caller-supplied location; pure function, no I/O. diff --git a/analysis/symbols_internal.go b/analysis/symbols_internal.go index d8f604a3..37fb991b 100644 --- a/analysis/symbols_internal.go +++ b/analysis/symbols_internal.go @@ -153,6 +153,25 @@ 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. + "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. + "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. @@ -261,6 +280,13 @@ var internalAllowedSymbols = []string{ "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. + "io.EOF", // 🟢 vmstat: sentinel error value returned by bufio.Scanner at end of file; pure constant. + "math.MaxUint64", // 🟢 vmstat: integer constant; bounds the /proc/meminfo KiB-to-bytes conversion against overflow; no side effects. + "os.Getpagesize", // 🟢 vmstat: returns the host's memory page size; read-only, no I/O. + "strconv.ParseFloat", // 🟢 vmstat: parses load-average and uptime float fields; pure function, no I/O. + "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.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.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..6c5ac7a5 --- /dev/null +++ b/builtins/internal/vmstat/vmstat.go @@ -0,0 +1,157 @@ +// 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 only. + 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 + // 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 | FieldMemoryDetail + +// 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..9f71bba1 --- /dev/null +++ b/builtins/internal/vmstat/vmstat_darwin_test.go @@ -0,0 +1,130 @@ +// 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) + 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) { + 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..0f400f6a --- /dev/null +++ b/builtins/internal/vmstat/vmstat_linux.go @@ -0,0 +1,339 @@ +// 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" + "math" + "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 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 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 + } + } + 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. 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) + // 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] + foundCPU = true + case strings.HasPrefix(line, "intr "): + st.Interrupts = parseUint64Field(line) + foundSystem = true + case strings.HasPrefix(line, "ctxt "): + st.ContextSwitches = parseUint64Field(line) + foundSystem = true + case strings.HasPrefix(line, "procs_running "): + st.ProcsRunning = parseUint64Field(line) + foundProcs = true + case strings.HasPrefix(line, "procs_blocked "): + st.ProcsBlocked = parseUint64Field(line) + foundProcs = true + } + }) + if err != nil { + return false, false, false, err + } + if !foundProcs && !foundSystem && !foundCPU { + return false, false, false, fmt.Errorf("vmstat: no recognised fields in %s", path) + } + 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. It reports +// 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 { + return + } + bytes := kb * 1024 + switch key { + case "MemTotal": + st.MemTotal, foundMemory = bytes, true + case "MemFree": + st.MemFree, foundMemoryDetail = bytes, true + case "Buffers": + st.MemBuffers, foundMemoryDetail = bytes, true + case "Cached": + st.MemCached, foundMemoryDetail = bytes, true + case "Active": + st.MemActive, foundMemoryDetail = bytes, true + case "Inactive": + st.MemInactive, foundMemoryDetail = bytes, true + case "SwapTotal": + st.SwapTotal, foundSwap = bytes, true + case "SwapFree": + st.SwapFree, foundSwap = bytes, true + } + }) + if err != nil { + return false, false, false, err + } + if !foundMemory && !foundMemoryDetail && !foundSwap { + return false, false, false, fmt.Errorf("vmstat: no recognised fields in %s", path) + } + return foundMemory, foundMemoryDetail, foundSwap, 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..324bc02a --- /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..ed318075 --- /dev/null +++ b/builtins/internal/vmstat/vmstat_linux_parse_test.go @@ -0,0 +1,211 @@ +// 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|FieldMemoryDetail|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 + 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) { + 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..9ac2be29 --- /dev/null +++ b/builtins/tests/vmstat/vmstat_linux_test.go @@ -0,0 +1,234 @@ +// 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 TestVmstatStatsIgnoresOperand(t *testing.T) { + writeSyntheticProc(t) + 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, "invalid delay") +} + +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..b175b0ac --- /dev/null +++ b/builtins/vmstat/vmstat.go @@ -0,0 +1,571 @@ +// 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} + } + + // 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) + } +} + +// 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 || c > uint64(math.MaxInt) { + 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. 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) + return builtins.Result{Code: 1} + } + + 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 + 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, 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") + 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.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") + 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. 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.FieldSwap != 0 { + swpd := subClamp(cur.SwapTotal, cur.SwapFree) + cells = append(cells, fmtScaled(swpd, divisor, w)) + } else { + cells = append(cells, dash(w)) + } + 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)) + } else { + cells = append(cells, fmtScaled(cur.MemBuffers, divisor, w), fmtScaled(cur.MemCached, divisor, w)) + } + } else { + cells = append(cells, 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 +} + +// 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) { + 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 + } + // 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 +} + +// 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 = satUint64(float64(inKB) / elapsedSeconds) + bo = satUint64(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 = satUint64(float64(intr) / elapsedSeconds) + cs = satUint64(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 4c5406aa..543ac05f 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -44,6 +44,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" ) @@ -89,6 +90,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..a4e096e1 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/basic/sampling.yaml @@ -0,0 +1,16 @@ +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 +# 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:$?" +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..3d617749 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/basic/snapshot.yaml @@ -0,0 +1,18 @@ +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:$?" +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..e8228557 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/basic/stats.yaml @@ -0,0 +1,17 @@ +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:$?" +expect: + stdout_contains: + - "total memory" + - "total swap" + - "code:0" + stderr: "" + exit_code: 0 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/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_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/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..6ca97af3 --- /dev/null +++ b/tests/scenarios/cmd/vmstat/flags/active_memory.yaml @@ -0,0 +1,17 @@ +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 +# 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:$?" +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/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 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..8338acbd --- /dev/null +++ b/tests/scenarios/cmd/vmstat/flags/unit_scale.yaml @@ -0,0 +1,16 @@ +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 +# 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:$?" + 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..b0b50c3d --- /dev/null +++ b/tests/scenarios/cmd/vmstat/flags/wide.yaml @@ -0,0 +1,19 @@ +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 +# 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) + 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 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) })