diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 0f6663f3..05417fb4 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -31,6 +31,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `test EXPRESSION` / `[ EXPRESSION ]` — evaluate conditional expression (file tests, string/integer comparison, logical operators) - ✅ `tr [-cdsCt] SET1 [SET2]` — translate, squeeze, and/or delete characters from stdin - ✅ `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 - ✅ `wc [-l] [-w] [-c] [-m] [-L] [FILE]...` — count lines, words, bytes, characters, or max line length - ❌ All other commands — return exit code 127 with `: not found` unless an ExecHandler is configured diff --git a/allowedsymbols/symbols_builtins.go b/allowedsymbols/symbols_builtins.go index dc806d86..707125db 100644 --- a/allowedsymbols/symbols_builtins.go +++ b/allowedsymbols/symbols_builtins.go @@ -278,6 +278,11 @@ var builtinPerCommandSymbols = map[string][]string{ "true": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. }, + "uname": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O. + "strings.Join", // 🟢 joins string slices; pure function, no I/O. + }, "uniq": { "bufio.NewScanner", // 🟢 line-by-line input reading (e.g. head, cat); no write or exec capability. "bufio.SplitFunc", // 🟢 type for custom scanner split functions; pure type, no I/O. diff --git a/allowedsymbols/symbols_internal.go b/allowedsymbols/symbols_internal.go index ccb6fde0..9237cc24 100644 --- a/allowedsymbols/symbols_internal.go +++ b/allowedsymbols/symbols_internal.go @@ -56,6 +56,20 @@ var internalPerPackageSymbols = map[string][]string{ "procpath": { // No stdlib symbols needed — this package only defines a string constant. }, + "procsyskernel": { + "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. + "io.LimitReader", // 🟢 wraps a reader with a byte cap; pure wrapper, no I/O by itself. + "io.ReadAll", // 🟠 reads all data from a reader; bounded by io.LimitReader. + "os.ModeCharDevice", // 🟢 file mode constant; pure constant. + "os.O_RDONLY", // 🟢 read-only file flag; pure constant. + "os.OpenFile", // 🟠 opens kernel pseudo-files for reading; bypasses AllowedPaths by design. + "path/filepath.Base", // 🟢 returns the last element of a path; validates name is a plain basename. + "path/filepath.Clean", // 🟢 normalises path before use; pure function, no I/O. + "path/filepath.Join", // 🟢 joins path elements; pure function, no I/O. + "strings.Contains", // 🟢 checks for ".." traversal in procPath; pure function, no I/O. + "strings.TrimRight", // 🟢 trims trailing characters; pure function, no I/O. + "syscall.O_NONBLOCK", // 🟢 non-blocking open flag; prevents FIFO hang. Pure constant. + }, "procnetroute": { "bufio.NewScanner", // 🟢 line-by-line reading of /proc/net/route; no write capability. "github.com/DataDog/rshell/builtins/internal/procpath.Default", // 🟢 canonical /proc filesystem root path constant; pure constant, no I/O. @@ -128,11 +142,17 @@ var internalAllowedSymbols = []string{ "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. "os.ErrNotExist", // 🟢 procinfo: sentinel error value indicating a file or directory does not exist; read-only constant, no I/O. "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. + "io.LimitReader", // 🟢 procsyskernel: wraps a reader with a byte cap; pure wrapper, no I/O by itself. + "io.ReadAll", // 🟠 procsyskernel: reads all data from a bounded reader; used with LimitReader for 4KiB cap. "os.Getpid", // 🟠 procinfo: returns the current process ID; read-only, no side effects. + "os.ModeCharDevice", // 🟢 procsyskernel: file mode constant for char device detection; pure constant. + "os.O_RDONLY", // 🟢 procsyskernel: read-only open flag; pure constant. "os.Open", // 🟠 procinfo: opens a file read-only; needed to stream /proc/stat line-by-line. + "os.OpenFile", // 🟠 procsyskernel: opens kernel pseudo-files with O_NONBLOCK; bypasses AllowedPaths by design. "os.ReadDir", // 🟠 procinfo: reads a directory listing; needed to enumerate /proc entries. "os.ReadFile", // 🟠 procinfo: reads a whole file; needed to read /proc/[pid]/{stat,cmdline,status}. "os.Stat", // 🟠 procinfo: validates that the proc path exists before enumeration; read-only metadata, no write capability. + "path/filepath.Base", // 🟢 procsyskernel: returns the last element of a path; validates name is a plain basename. "path/filepath.Clean", // 🟢 procnetroute/procnetsocket: normalises procPath before ".." safety check; pure function, no I/O. "path/filepath.Join", // 🟢 procinfo: joins path elements to construct /proc//stat paths; pure function, no I/O. "strconv.Atoi", // 🟢 string-to-int conversion; pure function, no I/O. @@ -153,6 +173,7 @@ var internalAllowedSymbols = []string{ "strings.TrimSpace", // 🟢 procinfo: removes leading/trailing whitespace; pure function, no I/O. "syscall.Errno", // 🟢 winnet: wraps DLL return code as an error type; pure type, no I/O. "syscall.Getsid", // 🟠 procinfo: returns the session ID of a process; read-only syscall, no write/exec. + "syscall.O_NONBLOCK", // 🟢 procsyskernel: non-blocking open flag to prevent FIFO hang; pure constant. "syscall.MustLoadDLL", // 🔴 winnet: loads iphlpapi.dll once at program init; read-only OS loader call. "syscall.Proc", // 🟢 winnet: DLL procedure handle type used in function signature; pure type, no I/O. "time.Now", // 🟠 procinfo: returns the current wall-clock time; read-only, no side effects. diff --git a/builtins/internal/procsyskernel/procsyskernel.go b/builtins/internal/procsyskernel/procsyskernel.go new file mode 100644 index 00000000..7481af52 --- /dev/null +++ b/builtins/internal/procsyskernel/procsyskernel.go @@ -0,0 +1,72 @@ +// 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 procsyskernel reads Linux kernel information from /proc/sys/kernel/. +// +// This package is in builtins/internal/ and is therefore exempt from the +// builtinAllowedSymbols allowlist check. It may use OS-specific APIs freely. +// +// # Sandbox bypass +// +// ReadFile intentionally bypasses the AllowedPaths sandbox (callCtx.OpenFile) +// and calls os.OpenFile directly. This is safe because procPath is always a +// kernel-managed pseudo-filesystem root (/proc by default) that is hardcoded +// by the caller — it is never derived from user-supplied input and cannot be +// redirected by a shell script. The caller is responsible for ensuring that +// procPath remains a safe, non-user-controlled path. +package procsyskernel + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" +) + +// ReadFile reads a single-line value from a /proc/sys/kernel/ pseudo-file. +// name is the filename (e.g. "ostype", "hostname"). procPath is the base +// proc path (e.g. "/proc" or "/host/proc"). +// +// The file is opened with O_NONBLOCK to prevent blocking on FIFOs, then +// validated via fstat to reject non-regular files. Reads are bounded to +// 4 KiB. The returned value is trimmed of trailing whitespace. +func ReadFile(procPath, name string) (string, error) { + // Defence-in-depth: reject ".." in the original path before Clean + // so traversal like "/proc/../etc/passwd" is caught. Matches the + // equivalent guard in procnetroute and procnetsocket. + if strings.Contains(procPath, "..") { + return "", fmt.Errorf("procsyskernel: unsafe procPath %q (must not contain \"..\" components)", procPath) + } + // Reject path components in name — must be a plain basename (e.g. "ostype"). + if name != filepath.Base(name) || strings.Contains(name, "..") { + return "", fmt.Errorf("procsyskernel: unsafe name %q (must be a plain filename)", name) + } + path := filepath.Join(filepath.Clean(procPath), "sys", "kernel", name) + // Open with O_NONBLOCK to prevent blocking on FIFOs, then validate + // the file type via fstat on the opened fd. This is atomic — no + // TOCTOU gap between type check and open. + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) + if err != nil { + return "", err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return "", err + } + if !info.Mode().IsRegular() && info.Mode().Type()&os.ModeCharDevice == 0 { + // Allow regular files and char devices (proc pseudo-files appear as + // char devices on some configurations). Reject FIFOs, sockets, etc. + return "", fmt.Errorf("not a regular file: %s", path) + } + // Proc kernel files are tiny single-line values. Cap at 4 KiB. + data, err := io.ReadAll(io.LimitReader(f, 4096)) + if err != nil { + return "", err + } + return strings.TrimRight(string(data), " \t\r\n"), nil +} diff --git a/builtins/proc_provider.go b/builtins/proc_provider.go index 9b832797..e80da0c3 100644 --- a/builtins/proc_provider.go +++ b/builtins/proc_provider.go @@ -9,6 +9,7 @@ import ( "context" "github.com/DataDog/rshell/builtins/internal/procinfo" + "github.com/DataDog/rshell/builtins/internal/procsyskernel" ) // ProcProvider gives builtins controlled access to the proc filesystem. @@ -26,6 +27,11 @@ func NewProcProvider(path string) *ProcProvider { return &ProcProvider{path: path} } +// ProcPath returns the configured proc filesystem path (e.g. "/proc" or "/host/proc"). +func (p *ProcProvider) ProcPath() string { + return p.path +} + // ListAll returns all running processes. func (p *ProcProvider) ListAll(ctx context.Context) ([]procinfo.ProcInfo, error) { return procinfo.ListAll(ctx, p.path) @@ -40,3 +46,10 @@ func (p *ProcProvider) GetSession(ctx context.Context) ([]procinfo.ProcInfo, err func (p *ProcProvider) GetByPIDs(ctx context.Context, pids []int) ([]procinfo.ProcInfo, error) { return procinfo.GetByPIDs(ctx, p.path, pids) } + +// ReadKernelFile reads a single-line value from a /proc/sys/kernel/ pseudo-file. +// name is the filename relative to sys/kernel/ (e.g. "ostype", "hostname"). +// The returned value is trimmed of trailing whitespace. +func (p *ProcProvider) ReadKernelFile(name string) (string, error) { + return procsyskernel.ReadFile(p.path, name) +} diff --git a/builtins/tests/uname/uname_test.go b/builtins/tests/uname/uname_test.go new file mode 100644 index 00000000..0567dfc9 --- /dev/null +++ b/builtins/tests/uname/uname_test.go @@ -0,0 +1,379 @@ +// 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 uname_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +// writeFakeProc creates a fake /proc/sys/kernel/ tree in dir. +func writeFakeProc(t *testing.T, dir string, vals map[string]string) { + t.Helper() + kernelDir := filepath.Join(dir, "sys", "kernel") + require.NoError(t, os.MkdirAll(kernelDir, 0755)) + for name, val := range vals { + require.NoError(t, os.WriteFile(filepath.Join(kernelDir, name), []byte(val+"\n"), 0644)) + } +} + +// defaultFakeProc returns a standard set of fake proc values. +func defaultFakeProc() map[string]string { + return map[string]string{ + "ostype": "Linux", + "hostname": "testhost", + "osrelease": "5.15.0-test", + "version": "#1 SMP Test", + "arch": "x86_64", + } +} + +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return runScriptCtx(context.Background(), t, script, dir, opts...) +} + +func runScriptCtx(ctx context.Context, t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader(script), "") + require.NoError(t, err) + var outBuf, errBuf bytes.Buffer + allOpts := append([]interp.RunnerOption{ + interp.StdIO(nil, &outBuf, &errBuf), + interpoption.AllowAllCommands().(interp.RunnerOption), + }, opts...) + runner, err := interp.New(allOpts...) + require.NoError(t, err) + defer runner.Close() + if dir != "" { + runner.Dir = dir + } + err = runner.Run(ctx, prog) + exitCode := 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else if ctx.Err() != nil { + exitCode = 1 // Context cancelled/timed out. + } else { + t.Fatalf("unexpected error: %v", err) + } + } + return outBuf.String(), errBuf.String(), exitCode +} + +func cmdRun(t *testing.T, script, procDir string) (stdout, stderr string, exitCode int) { + t.Helper() + return runScript(t, script, procDir, interp.ProcPath(procDir)) +} + +func requireLinux(t *testing.T) { + t.Helper() + if runtime.GOOS != "linux" { + t.Skip("uname reads from /proc; skipping on " + runtime.GOOS) + } +} + +// --- Tests --- + +func TestUnameDefault(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux\n", stdout) +} + +func TestUnameS(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname -s", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux\n", stdout) +} + +func TestUnameN(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname -n", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "testhost\n", stdout) +} + +func TestUnameR(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname -r", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "5.15.0-test\n", stdout) +} + +func TestUnameV(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname -v", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "#1 SMP Test\n", stdout) +} + +func TestUnameM(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname -m", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "x86_64\n", stdout) +} + +func TestUnameA(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname -a", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux testhost 5.15.0-test #1 SMP Test x86_64\n", stdout) +} + +func TestUnameCombinedFlags(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname -sn", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux testhost\n", stdout) +} + +func TestUnameCombinedFlagsMR(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname -mr", dir) + assert.Equal(t, 0, code) + // Output order follows POSIX: s, n, r, v, m — so -mr gives "release machine" + assert.Equal(t, "5.15.0-test x86_64\n", stdout) +} + +func TestUnameUnknownFlag(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + _, stderr, code := cmdRun(t, "uname -z", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "uname:") +} + +func TestUnameMissingProcFile(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + // Don't create any proc files — all reads should fail. + _, stderr, code := cmdRun(t, "uname", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "uname: cannot read") +} + +func TestUnameCustomProcPath(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + customProc := filepath.Join(dir, "host", "proc") + writeFakeProc(t, customProc, map[string]string{ + "ostype": "Linux", + "hostname": "container-host", + "osrelease": "6.1.0-custom", + "version": "#42 SMP Custom", + "arch": "aarch64", + }) + stdout, _, code := runScript(t, "uname -a", dir, interp.ProcPath(customProc)) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux container-host 6.1.0-custom #42 SMP Custom aarch64\n", stdout) +} + +func TestUnameHelp(t *testing.T) { + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname --help", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "uname") +} + +func TestUnameNoProcFiles(t *testing.T) { + requireLinux(t) + // Point proc path at an empty directory — no kernel files exist. + dir := t.TempDir() + emptyProc := filepath.Join(dir, "empty_proc") + require.NoError(t, os.MkdirAll(emptyProc, 0755)) + _, stderr, code := runScript(t, "uname", dir, interp.ProcPath(emptyProc)) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "uname: cannot read") +} + +func TestUnameNonLinuxPlatform(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("this test verifies non-Linux behavior") + } + dir := t.TempDir() + _, stderr, code := cmdRun(t, "uname", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "not supported") +} + +func TestUnameDuplicateFlags(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + // -ss should print kernel name once, not twice. + stdout, _, code := cmdRun(t, "uname -ss", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux\n", stdout) +} + +func TestUnameAllFlagsExplicit(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + // -snrvm should produce the same output as -a. + stdout, _, code := cmdRun(t, "uname -snrvm", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux testhost 5.15.0-test #1 SMP Test x86_64\n", stdout) +} + +func TestUnameFlagOrderDoesntMatter(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + // -mrvns (reverse order) should still output in POSIX order: s,n,r,v,m. + stdout, _, code := cmdRun(t, "uname -mrvns", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux testhost 5.15.0-test #1 SMP Test x86_64\n", stdout) +} + +func TestUnameAllOverridesIndividual(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + // -as should produce the same output as -a. + stdout, _, code := cmdRun(t, "uname -as", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux testhost 5.15.0-test #1 SMP Test x86_64\n", stdout) +} + +func TestUnamePartialProcTreeSuccess(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + // Only create ostype — requesting -s should succeed. + writeFakeProc(t, dir, map[string]string{"ostype": "Linux"}) + stdout, _, code := cmdRun(t, "uname -s", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux\n", stdout) +} + +func TestUnamePartialProcTreeFailure(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + // Only create ostype — requesting -n should fail (hostname missing). + writeFakeProc(t, dir, map[string]string{"ostype": "Linux"}) + _, stderr, code := cmdRun(t, "uname -n", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "uname: cannot read hostname") +} + +func TestUnameWhitespaceInProcValues(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, map[string]string{ + "ostype": "Linux", + "hostname": "myhost \t", + "osrelease": "5.15.0", + "version": "#1 SMP", + "arch": "x86_64", + }) + stdout, _, code := cmdRun(t, "uname -n", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "myhost\n", stdout, "trailing whitespace should be trimmed") +} + +func TestUnameEmptyProcFile(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + // ostype exists but writeFakeProc adds "\n" — write truly empty file. + kernelDir := filepath.Join(dir, "sys", "kernel") + require.NoError(t, os.MkdirAll(kernelDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(kernelDir, "ostype"), []byte(""), 0644)) + stdout, _, code := cmdRun(t, "uname -s", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "\n", stdout, "empty proc file should produce empty field") +} + +func TestUnamePipeIntegration(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, "uname -s | cat", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux\n", stdout) +} + +func TestUnameVariableCapture(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + stdout, _, code := cmdRun(t, `x=$(uname -s); echo "$x"`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "Linux\n", stdout) +} + +func TestUnameContextCancellation(t *testing.T) { + requireLinux(t) + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + _, _, code := runScriptCtx(ctx, t, "uname -a", dir, interp.ProcPath(dir)) + assert.NotEqual(t, 0, code, "cancelled context should result in non-zero exit") +} + +func TestUnameHelpShortFlag(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, "uname -h", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "uname") + assert.Empty(t, stderr, "help should not write to stderr") +} + +func TestUnameHelpStderrEmpty(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, "uname --help", dir) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) +} + +func TestUnameExtraOperandRejected(t *testing.T) { + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + // GNU uname rejects extra operands. + _, stderr, code := cmdRun(t, "uname foo", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "uname: extra operand") +} diff --git a/builtins/uname/uname.go b/builtins/uname/uname.go new file mode 100644 index 00000000..cc06dc01 --- /dev/null +++ b/builtins/uname/uname.go @@ -0,0 +1,147 @@ +// 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 uname implements the uname builtin command. +// +// uname — print system information +// +// Usage: uname [-asnrvm] [--help] +// +// Print certain system information. With no flags, same as -s. +// +// Reads system information from /proc/sys/kernel/ pseudo-files via the +// ProcProvider. The proc path is configurable via the --proc-path CLI +// flag or interp.ProcPath() API option (e.g., /host/proc for containers). +// +// Flags: +// +// -s Print the kernel name (default when no flags given) +// -n Print the network node hostname +// -r Print the kernel release +// -v Print the kernel version +// -m Print the machine hardware name +// -a Print all of the above, in the order shown +// -h, --help Display help and exit +// +// Data sources (relative to proc path): +// +// -s sys/kernel/ostype +// -n sys/kernel/hostname +// -r sys/kernel/osrelease +// -v sys/kernel/version +// -m sys/kernel/arch +// +// Exit codes: +// +// 0 Success — requested information was written. +// 1 Error — unsupported platform, missing proc file, or invalid flag. +package uname + +import ( + "context" + "runtime" + "strings" + + "github.com/DataDog/rshell/builtins" +) + +// Cmd is the uname builtin command descriptor. +var Cmd = builtins.Command{ + Name: "uname", + Description: "print system information", + Help: `uname: uname [-asnrvm] + Print system information. + + With no flags, print the kernel name (same as -s). + Reads from /proc/sys/kernel/ (configurable via --proc-path).`, + MakeFlags: makeFlags, +} + +// kernelFiles maps each flag letter to the proc pseudo-file that +// provides the corresponding value. Order matches POSIX -a output. +var kernelFiles = [...]struct { + short string + long string + file string +}{ + {"s", "kernel-name", "ostype"}, + {"n", "nodename", "hostname"}, + {"r", "kernel-release", "osrelease"}, + {"v", "kernel-version", "version"}, + {"m", "machine", "arch"}, // Available since Linux 2.6 (2003); last 2.6 LTS (2.6.32) EOL Feb 2016. +} + +func makeFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + help := fs.BoolP("help", "h", false, "print usage and exit") + var flags [len(kernelFiles)]*bool + for i, entry := range kernelFiles { + flags[i] = fs.BoolP(entry.long, entry.short, false, "") + } + allFlag := fs.BoolP("all", "a", false, "print all information") + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *help { + callCtx.Outf("Usage: uname [OPTION]...\n") + callCtx.Outf("Print system information. With no OPTION, same as -s.\n\n") + callCtx.Outf(" -s, --kernel-name print the kernel name\n") + callCtx.Outf(" -n, --nodename print the network node hostname\n") + callCtx.Outf(" -r, --kernel-release print the kernel release\n") + callCtx.Outf(" -v, --kernel-version print the kernel version\n") + callCtx.Outf(" -m, --machine print the machine hardware name\n") + callCtx.Outf(" -a, --all print all information\n") + callCtx.Outf(" --help display this help and exit\n") + return builtins.Result{} + } + + if len(args) > 0 { + callCtx.Errf("uname: extra operand '%s'\n", args[0]) + callCtx.Errf("Try 'uname --help' for more information.\n") + return builtins.Result{Code: 1} + } + + if runtime.GOOS != "linux" { + callCtx.Errf("uname: not supported on %s (Linux only)\n", runtime.GOOS) + return builtins.Result{Code: 1} + } + + if callCtx.Proc == nil { + callCtx.Errf("uname: not supported (no proc filesystem configured)\n") + return builtins.Result{Code: 1} + } + + // Default: -s when no flags given. + anySet := *allFlag + if !anySet { + for _, f := range flags { + if *f { + anySet = true + break + } + } + } + if !anySet { + *flags[0] = true // -s + } + + var parts []string + for i, entry := range kernelFiles { + if !*allFlag && !*flags[i] { + continue + } + if ctx.Err() != nil { + return builtins.Result{Code: 1} + } + val, err := callCtx.Proc.ReadKernelFile(entry.file) + if err != nil { + callCtx.Errf("uname: cannot read %s: %s\n", entry.file, err) + return builtins.Result{Code: 1} + } + parts = append(parts, val) + } + + callCtx.Outf("%s\n", strings.Join(parts, " ")) + return builtins.Result{} + } +} diff --git a/interp/register_builtins.go b/interp/register_builtins.go index bcb766d3..d16f1b69 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -33,6 +33,7 @@ import ( "github.com/DataDog/rshell/builtins/testcmd" "github.com/DataDog/rshell/builtins/tr" truecmd "github.com/DataDog/rshell/builtins/true" + "github.com/DataDog/rshell/builtins/uname" "github.com/DataDog/rshell/builtins/uniq" "github.com/DataDog/rshell/builtins/wc" ) @@ -67,6 +68,7 @@ func registerBuiltins() { testcmd.BracketCmd, tr.Cmd, truecmd.Cmd, + uname.Cmd, uniq.Cmd, wc.Cmd, } { diff --git a/tests/scenarios/cmd/uname/basic/help.yaml b/tests/scenarios/cmd/uname/basic/help.yaml new file mode 100644 index 00000000..10a2cf48 --- /dev/null +++ b/tests/scenarios/cmd/uname/basic/help.yaml @@ -0,0 +1,11 @@ +# skip: GNU uname --help includes flags we don't support (-p, -i, -o, --version) +# and GNU-specific footer text. Help format intentionally differs. +description: uname --help prints usage to stdout +skip_assert_against_bash: true +input: + script: |+ + uname --help +expect: + stdout_contains: ["uname", "kernel-name", "nodename", "kernel-release", "kernel-version", "machine"] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/uname/errors/extra_operand.yaml b/tests/scenarios/cmd/uname/errors/extra_operand.yaml new file mode 100644 index 00000000..2e9cb400 --- /dev/null +++ b/tests/scenarios/cmd/uname/errors/extra_operand.yaml @@ -0,0 +1,8 @@ +description: uname rejects extra positional operands +input: + script: |+ + uname foo +expect: + stdout: "" + stderr: "uname: extra operand 'foo'\nTry 'uname --help' for more information.\n" + exit_code: 1 diff --git a/tests/scenarios/cmd/uname/errors/unknown_flag.yaml b/tests/scenarios/cmd/uname/errors/unknown_flag.yaml new file mode 100644 index 00000000..a8436255 --- /dev/null +++ b/tests/scenarios/cmd/uname/errors/unknown_flag.yaml @@ -0,0 +1,11 @@ +# skip: pflag formats unknown-flag errors differently from GNU coreutils +# ("unknown shorthand flag" vs "invalid option"). Exit code matches (1). +description: uname rejects unknown flags +skip_assert_against_bash: true +input: + script: |+ + uname -z +expect: + stdout: "" + stderr_contains: ["uname:"] + exit_code: 1