diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index bd063a52..31722bac 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -132,6 +132,8 @@ var builtinPerCommandSymbols = map[string][]string{ "help": { "bytes.Buffer", // 🟢 in-memory buffer to capture --help output from commands; no I/O side effects. "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "github.com/DataDog/rshell/internal/version.Version", // 🟢 build version string; read-only package-level variable, no I/O. + "strings.Builder", // 🟢 efficient string concatenation; pure in-memory buffer, no I/O. }, "head": { "bufio.NewScanner", // 🟢 line-by-line input reading (e.g. head, cat); no write or exec capability. @@ -413,6 +415,7 @@ var builtinAllowedSymbols = []string{ "errors.New", // 🟢 creates a simple error value; pure function, no I/O. "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. + "github.com/DataDog/rshell/internal/version.Version", // 🟢 build version string; read-only package-level variable, no I/O. "github.com/prometheus-community/pro-bing.NewPinger", // 🔴 creates an ICMP pinger by resolving host; network I/O is the explicit purpose of the ping builtin. "github.com/prometheus-community/pro-bing.NoopLogger", // 🟢 no-op logger that discards pro-bing internal messages; no side effects. "github.com/prometheus-community/pro-bing.Packet", // 🟢 ICMP packet descriptor struct (received packet data); pure data type, no I/O. diff --git a/builtins/help/help.go b/builtins/help/help.go index 2e6641df..8790e339 100644 --- a/builtins/help/help.go +++ b/builtins/help/help.go @@ -7,11 +7,16 @@ // // help — display help for commands // -// Usage: help [command] +// Usage: help [--all] [command] // -// With no arguments, list all available builtin commands with a brief -// description. When a command name is given, display detailed help for -// that command. +// With no arguments, list allowed builtin commands with descriptions +// and a compact list of not-allowed builtins. When --all is given, +// both sections are shown as full description tables. When a command +// name is given, display detailed help for that command. +// +// Flags: +// +// --all show all builtins (including not allowed) with descriptions // // Exit codes: // @@ -22,8 +27,10 @@ package help import ( "bytes" "context" + "strings" "github.com/DataDog/rshell/builtins" + "github.com/DataDog/rshell/internal/version" ) // Cmd is the help builtin command descriptor. @@ -34,12 +41,13 @@ var Cmd = builtins.Command{ } func printUsage(callCtx *builtins.CallContext) { - callCtx.Out("Usage: help [command]\n") + callCtx.Out("Usage: help [--all] [command]\n") callCtx.Out("Display help for builtin commands.\n") } func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { helpFlag := fs.Bool("help", false, "print usage and exit") + allFlag := fs.Bool("all", false, "show all builtins (including not allowed) with descriptions") return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if *helpFlag { @@ -88,30 +96,108 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { return builtins.Result{} } - // No arguments — list all allowed commands. + // No arguments — list commands. allNames := builtins.Names() - var names []string + var allowed, notAllowed []string for _, name := range allNames { if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(name) { + notAllowed = append(notAllowed, name) continue } - names = append(names, name) + allowed = append(allowed, name) } - // Find the longest command name for alignment. - maxLen := 0 - for _, name := range names { - if len(name) > maxLen { - maxLen = len(name) - } - } + // Header: version + command counts. + printHeader(callCtx, len(allowed), len(allNames)) + + // Print allowed commands with descriptions. + printCommandTable(callCtx, allowed) - for _, name := range names { - meta, _ := builtins.Meta(name) - callCtx.Outf("%-*s %s\n", maxLen, name, meta.Description) + // Show disabled builtins when restrictions are active. + if len(notAllowed) > 0 { + label := "Disabled builtins" + if len(notAllowed) == 1 { + label = "Disabled builtin" + } + if *allFlag { + // --all: full description table for disabled builtins. + callCtx.Outf("\n%s:\n", label) + printCommandTable(callCtx, notAllowed) + } else { + // Default: compact comma-separated list. + callCtx.Outf("\n%s: %s\n", label, wrapNames(notAllowed, 80)) + } + } else if *allFlag { + callCtx.Out("\nAll builtins are allowed in this session.\n") } callCtx.Out("\nRun 'help ' for more information on a specific command.\n") return builtins.Result{} } } + +// printHeader writes the version line and command count summary. +func printHeader(callCtx *builtins.CallContext, allowed, total int) { + var header strings.Builder + header.WriteString("rshell") + if version.Version != "" { + header.WriteString(" (") + header.WriteString(version.Version) + header.WriteByte(')') + } + header.WriteString(" — ") + if allowed < total { + callCtx.Outf("%s%d of %d builtins enabled\n\n", header.String(), allowed, total) + } else { + callCtx.Outf("%sAll %d builtins available\n\n", header.String(), total) + } +} + +// printCommandTable prints an aligned name/description table. +func printCommandTable(callCtx *builtins.CallContext, names []string) { + maxLen := 0 + for _, name := range names { + if len(name) > maxLen { + maxLen = len(name) + } + } + for _, name := range names { + meta, _ := builtins.Meta(name) + callCtx.Outf("%-*s %s\n", maxLen, name, meta.Description) + } +} + +// wrapNames formats a list of names into comma-separated lines that stay +// within the given line width. Continuation lines are indented by two spaces. +func wrapNames(names []string, lineWidth int) string { + if len(names) == 0 { + return "" + } + var b strings.Builder + col := 0 + lineStart := true // true at the very start and after each newline indent + for i, name := range names { + token := name + if i < len(names)-1 { + token += "," + } + needed := len(token) + if !lineStart { + needed++ // space before token + } + if !lineStart && col+needed > lineWidth { + b.WriteString("\n ") + col = 2 + lineStart = true + needed = len(token) + } + if !lineStart { + b.WriteByte(' ') + col++ + } + b.WriteString(token) + col += len(token) + lineStart = false + } + return b.String() +} diff --git a/builtins/tests/help/help_test.go b/builtins/tests/help/help_test.go index 77faf997..cb5f0148 100644 --- a/builtins/tests/help/help_test.go +++ b/builtins/tests/help/help_test.go @@ -61,6 +61,23 @@ func TestHelpExitCode(t *testing.T) { assert.NotEmpty(t, stdout) } +// --- Header --- + +func TestHelpHeaderShowsRshell(t *testing.T) { + stdout, _, code := runScript(t, "help", "", interpoption.AllowAllCommands().(interp.RunnerOption)) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "rshell") + assert.Contains(t, stdout, "All ") +} + +func TestHelpHeaderRestrictedShowsCount(t *testing.T) { + stdout, _, code := runScript(t, "help", "", + interp.AllowedCommands([]string{"rshell:echo", "rshell:help"})) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "2 of") + assert.Contains(t, stdout, "builtins enabled") +} + // --- Output content --- func TestHelpListsAllBuiltins(t *testing.T) { @@ -84,10 +101,10 @@ func TestHelpListsSorted(t *testing.T) { assert.Equal(t, 0, code) lines := strings.Split(strings.TrimSpace(stdout), "\n") - // Last line is the footer hint — exclude it and the blank line before it. + // Skip the header (first two lines: header + blank) and footer. var cmdLines []string for _, line := range lines { - if line == "" || strings.HasPrefix(line, "Run '") { + if line == "" || strings.HasPrefix(line, "Run '") || strings.HasPrefix(line, "rshell") { continue } cmdLines = append(cmdLines, line) @@ -138,7 +155,7 @@ func TestHelpColumnsAligned(t *testing.T) { // followed by a non-space character. descCol := -1 for _, line := range lines { - if line == "" || strings.HasPrefix(line, "Run '") { + if line == "" || strings.HasPrefix(line, "Run '") || strings.HasPrefix(line, "rshell") { continue } // Walk backwards from the end to find where the description text starts. @@ -166,16 +183,39 @@ func TestHelpColumnsAligned(t *testing.T) { // --- Restricted commands --- -func TestHelpRestrictedShowsOnlyAllowed(t *testing.T) { +func TestHelpRestrictedShowsOnlyAllowedInTable(t *testing.T) { stdout, stderr, code := runScript(t, "help", "", interp.AllowedCommands([]string{"rshell:echo", "rshell:help"})) assert.Equal(t, 0, code) assert.Empty(t, stderr) assert.Contains(t, stdout, "echo") assert.Contains(t, stdout, "help") - assert.NotContains(t, stdout, "cat") - assert.NotContains(t, stdout, "grep") - assert.NotContains(t, stdout, "ls") + // The allowed-commands table (lines before "Disabled builtin") should only + // contain allowed commands. The "Disabled builtin" line lists the rest. + inAllowedSection := true + for _, line := range strings.Split(stdout, "\n") { + if strings.HasPrefix(line, "Disabled builtin") { + inAllowedSection = false + } + if !inAllowedSection || strings.HasPrefix(line, "rshell") || line == "" || strings.HasPrefix(line, "Run '") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + assert.True(t, fields[0] == "echo" || fields[0] == "help", + "unexpected command in allowed table: %q", fields[0]) + } + } +} + +func TestHelpRestrictedShowsNotAllowedList(t *testing.T) { + stdout, _, code := runScript(t, "help", "", + interp.AllowedCommands([]string{"rshell:echo", "rshell:help"})) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Disabled builtin") + assert.Contains(t, stdout, "cat") + assert.Contains(t, stdout, "grep") + assert.Contains(t, stdout, "ls") } func TestHelpRestrictedSingleCommand(t *testing.T) { @@ -184,7 +224,6 @@ func TestHelpRestrictedSingleCommand(t *testing.T) { assert.Equal(t, 0, code) assert.Contains(t, stdout, "help") assert.Contains(t, stdout, "ls") - assert.NotContains(t, stdout, "echo") } func TestHelpRestrictedAlignmentAdjusts(t *testing.T) { @@ -196,7 +235,7 @@ func TestHelpRestrictedAlignmentAdjusts(t *testing.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") for _, line := range lines { - if line == "" || strings.HasPrefix(line, "Run '") { + if line == "" || strings.HasPrefix(line, "Run '") || strings.HasPrefix(line, "rshell") { continue } // "strings" is the longest name (7 chars), so the description should @@ -226,6 +265,36 @@ func TestHelpAlwaysAvailableNoCommands(t *testing.T) { assert.Contains(t, stderr, "command not allowed") } +// --- --all flag --- + +func TestHelpAllFlagShowsNotAllowedWithDescriptions(t *testing.T) { + stdout, _, code := runScript(t, "help --all", "", + interp.AllowedCommands([]string{"rshell:echo", "rshell:help"})) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Disabled builtin") + // --all shows full description table for not-allowed commands. + assert.Contains(t, stdout, "concatenate and print files") // cat description + assert.Contains(t, stdout, "print lines that match patterns") // grep description +} + +func TestHelpAllFlagNoRestrictions(t *testing.T) { + // When all commands are allowed, --all should not show "Disabled builtin" + // but should confirm that all builtins are allowed. + stdout, _, code := runScript(t, "help --all", "", interpoption.AllowAllCommands().(interp.RunnerOption)) + assert.Equal(t, 0, code) + assert.NotContains(t, stdout, "Disabled builtin") + assert.Contains(t, stdout, "All builtins are allowed in this session.") +} + +func TestHelpAllFlagStillShowsAllowed(t *testing.T) { + stdout, _, code := runScript(t, "help --all", "", + interp.AllowedCommands([]string{"rshell:echo", "rshell:help"})) + assert.Equal(t, 0, code) + // Allowed commands should still appear with descriptions. + assert.Contains(t, stdout, "write arguments to stdout") + assert.Contains(t, stdout, "display help for commands") +} + // --- Error handling --- func TestHelpUnknownCommandShowsError(t *testing.T) { diff --git a/tests/scenarios/cmd/help/list_commands.yaml b/tests/scenarios/cmd/help/list_commands.yaml deleted file mode 100644 index 6d17a968..00000000 --- a/tests/scenarios/cmd/help/list_commands.yaml +++ /dev/null @@ -1,9 +0,0 @@ -description: Help lists all available builtin commands. -skip_assert_against_bash: true -input: - script: |+ - help -expect: - stdout_contains: ["cat", "echo", "grep", "help", "ls", "test", "true", "false"] - stderr: "" - exit_code: 0 diff --git a/tests/scenarios/cmd/help/restricted_commands.yaml b/tests/scenarios/cmd/help/restricted.yaml similarity index 59% rename from tests/scenarios/cmd/help/restricted_commands.yaml rename to tests/scenarios/cmd/help/restricted.yaml index 9f7ed8e4..bc2e34ce 100644 --- a/tests/scenarios/cmd/help/restricted_commands.yaml +++ b/tests/scenarios/cmd/help/restricted.yaml @@ -5,10 +5,15 @@ input: script: |+ help expect: - stdout: | + stdout: |+ + rshell (dev) — 2 of 28 builtins enabled + echo write arguments to stdout help display help for commands + Disabled builtins: [, break, cat, continue, cut, exit, false, find, grep, head, ip, ls, ping, + printf, ps, sed, sort, ss, strings, tail, test, tr, true, uname, uniq, wc + Run 'help ' for more information on a specific command. stderr: "" exit_code: 0 diff --git a/tests/scenarios/cmd/help/restricted_all_flag.yaml b/tests/scenarios/cmd/help/restricted_all_flag.yaml new file mode 100644 index 00000000..b7b077c5 --- /dev/null +++ b/tests/scenarios/cmd/help/restricted_all_flag.yaml @@ -0,0 +1,44 @@ +description: Help --all shows allowed and not-allowed commands with descriptions. +skip_assert_against_bash: true +input: + allowed_commands: ["rshell:echo", "rshell:help"] + script: |+ + help --all +expect: + stdout: |+ + rshell (dev) — 2 of 28 builtins enabled + + echo write arguments to stdout + help display help for commands + + Disabled builtins: + [ evaluate conditional expression + break exit from a loop + cat concatenate and print files + continue continue a loop iteration + cut remove sections from each line + exit exit the shell + false return unsuccessful exit status + find search for files in a directory hierarchy + grep print lines that match patterns + head output the first part of files + ip show network interface and routing information + ls list directory contents + ping send ICMP echo requests to a network host + printf format and print data + ps report process status + sed stream editor for filtering and transforming text + sort sort lines of text files + ss display socket statistics + strings print printable character sequences + tail output the last part of files + test evaluate conditional expression + tr translate or delete characters + true return successful exit status + uname print system information + uniq report or omit repeated lines + wc print newline, word, and byte counts + + Run 'help ' for more information on a specific command. + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/help/unrestricted.yaml b/tests/scenarios/cmd/help/unrestricted.yaml new file mode 100644 index 00000000..3b2d164c --- /dev/null +++ b/tests/scenarios/cmd/help/unrestricted.yaml @@ -0,0 +1,41 @@ +description: Help lists all available builtin commands. +skip_assert_against_bash: true +input: + script: |+ + help +expect: + stdout: |+ + rshell (dev) — All 28 builtins available + + [ evaluate conditional expression + break exit from a loop + cat concatenate and print files + continue continue a loop iteration + cut remove sections from each line + echo write arguments to stdout + exit exit the shell + false return unsuccessful exit status + find search for files in a directory hierarchy + grep print lines that match patterns + head output the first part of files + help display help for commands + ip show network interface and routing information + ls list directory contents + ping send ICMP echo requests to a network host + printf format and print data + ps report process status + sed stream editor for filtering and transforming text + sort sort lines of text files + ss display socket statistics + strings print printable character sequences + tail output the last part of files + test evaluate conditional expression + tr translate or delete characters + true return successful exit status + uname print system information + uniq report or omit repeated lines + wc print newline, word, and byte counts + + Run 'help ' for more information on a specific command. + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/help/unrestricted_all_flag.yaml b/tests/scenarios/cmd/help/unrestricted_all_flag.yaml new file mode 100644 index 00000000..fc0b019a --- /dev/null +++ b/tests/scenarios/cmd/help/unrestricted_all_flag.yaml @@ -0,0 +1,43 @@ +description: Help --all with no restrictions shows all builtins and a confirmation message. +skip_assert_against_bash: true +input: + script: |+ + help --all +expect: + stdout: |+ + rshell (dev) — All 28 builtins available + + [ evaluate conditional expression + break exit from a loop + cat concatenate and print files + continue continue a loop iteration + cut remove sections from each line + echo write arguments to stdout + exit exit the shell + false return unsuccessful exit status + find search for files in a directory hierarchy + grep print lines that match patterns + head output the first part of files + help display help for commands + ip show network interface and routing information + ls list directory contents + ping send ICMP echo requests to a network host + printf format and print data + ps report process status + sed stream editor for filtering and transforming text + sort sort lines of text files + ss display socket statistics + strings print printable character sequences + tail output the last part of files + test evaluate conditional expression + tr translate or delete characters + true return successful exit status + uname print system information + uniq report or omit repeated lines + wc print newline, word, and byte counts + + All builtins are allowed in this session. + + Run 'help ' for more information on a specific command. + stderr: "" + exit_code: 0