Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions analysis/symbols_builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
122 changes: 104 additions & 18 deletions builtins/help/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
//
Expand All @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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 <command>' 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()
}
87 changes: 78 additions & 9 deletions builtins/tests/help/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 0 additions & 9 deletions tests/scenarios/cmd/help/list_commands.yaml

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>' for more information on a specific command.
stderr: ""
exit_code: 0
Loading
Loading