diff --git a/docs/play/controls-and-commands.md b/docs/play/controls-and-commands.md index 7471fb0..6455fba 100644 --- a/docs/play/controls-and-commands.md +++ b/docs/play/controls-and-commands.md @@ -46,7 +46,9 @@ Command lists join commands on one line, as in `sh`: Output redirection with `>` empties its file before the command runs, so `sort notes.txt > notes.txt` leaves an empty file, just as on Linux. If the command fails, the file is put back. Wildcards skip hidden names unless the pattern starts with a dot: `*` ignores `.env`, while `.*` matches it. -Background jobs (`&`), subshells, command substitution, special parameters such as `$?`, and file-descriptor redirection such as `2>` are rejected with an explanation before anything runs. Quote or escape these characters to use them as data. +`$?` holds the exit status of the last command, as on Linux: `ls /missing; echo $?` prints `2`. The lab reports `0` for success, `1` for most failures (including `grep` without a match and `false`), `2` for shell syntax errors and `ls`, `grep`, or `sort` trouble, `126` for a script that exists but cannot run, and `127` for a command the lab does not provide. A blank line keeps the previous status, and each script starts at `0`. + +Background jobs (`&`), subshells, command substitution, other special parameters such as `$1` or `$$`, and file-descriptor redirection such as `2>` are rejected with an explanation before anything runs. Quote or escape these characters to use them as data. ## Mission navigation diff --git a/docs/technical/diagrams/command-execution-pipeline.mmd b/docs/technical/diagrams/command-execution-pipeline.mmd index 0380994..3a9edb7 100644 --- a/docs/technical/diagrams/command-execution-pipeline.mmd +++ b/docs/technical/diagrams/command-execution-pipeline.mmd @@ -3,7 +3,9 @@ flowchart LR INPUT["Player command line"] --> LINE{"At most 64 KiB?"} LINE -- No --> E1["Reject
history unchanged"] LINE -- Yes --> HISTORY["Record in bounded
attempt history"] - HISTORY --> LEX["Lex
quotes · escapes · variables
comments · pipes · redirection"] + HISTORY --> SPLIT{"Split ; && || lists
every pipeline's syntax valid?"} + SPLIT -- No --> E4["Reject the whole line
nothing runs · $? = 2"] + SPLIT -- Yes --> LEX["Lex one pipeline
quotes · escapes · variables · $?
comments · pipes · redirection"] LEX --> PARSE["Parse
pipeline stages and redirects"] PARSE --> EXPAND["Expand
virtual globs and arguments"] EXPAND --> PREFLIGHT{"Composition and
resource checks pass?"} @@ -14,12 +16,15 @@ flowchart LR STATE --> OUTPUT{"Output within limit?"} OUTPUT -- No --> E3["Reject bounded output"] OUTPUT -- Yes --> REDIRECT{"Virtual output
redirection?"} - REDIRECT -- Yes --> WRITE["Preflight and write
virtual destination"] + REDIRECT -- Yes --> WRITE["Write virtual destination
> empties it before the stage
restored if the stage fails"] REDIRECT -- No --> NEXT{"Another pipeline stage?"} WRITE --> NEXT NEXT -- Yes --> READ - NEXT -- No --> RESULT["Execution result
output · practiced commands
pipeline width · editor request"] - RESULT --> VALIDATE["Outcome evaluation
text comparison + Environment.Observe"] + NEXT -- No --> RESULT["Pipeline result
output · exit status for $?
practiced commands"] + RESULT --> LIST{"Another pipeline
the operators allow?"} + LIST -- "Yes: ; always · && after success · || after failure" --> LEX + LIST -- No --> FINAL["Execution result
ordered output and errors
pipeline width · editor request"] + FINAL --> VALIDATE["Outcome evaluation
text comparison + Environment.Observe"] VFS[("In-memory filesystem")] ENV[("Virtual environment")] @@ -41,8 +46,8 @@ flowchart LR classDef invariant fill:#ede9fe,color:#4c1d95,stroke:#7c3aed,stroke-width:2px class INPUT input - class HISTORY,LEX,PARSE,EXPAND,READ,DISPATCH,RESULT,VALIDATE stage + class HISTORY,LEX,PARSE,EXPAND,READ,DISPATCH,RESULT,FINAL,VALIDATE stage class STATE,WRITE,VFS,ENV,PROC,ARC state - class LINE,PREFLIGHT,OUTPUT,REDIRECT,NEXT decision - class E1,E2,E3 error + class LINE,SPLIT,PREFLIGHT,OUTPUT,REDIRECT,NEXT,LIST decision + class E1,E2,E3,E4 error class HOST invariant diff --git a/docs/technical/diagrams/command-execution-pipeline.svg b/docs/technical/diagrams/command-execution-pipeline.svg index 0e78e56..20d166e 100644 --- a/docs/technical/diagrams/command-execution-pipeline.svg +++ b/docs/technical/diagrams/command-execution-pipeline.svg @@ -1,7 +1,7 @@ Teaching-shell command execution pipeline - Flow from bounded player input through lexing, parsing, virtual expansion, preflight, Go command dispatch, virtual state, redirection, execution metadata, and mission outcome evaluation. + Flow from bounded player input through command-list splitting, per-pipeline lexing, parsing, virtual expansion, preflight, Go command dispatch, virtual state, redirection, exit status, a loop for the next pipeline in a list, execution metadata, and mission outcome evaluation. @@ -17,8 +17,8 @@ FAILED GATE → bounded error · no external fallback - - + + @@ -30,14 +30,17 @@ no + + next pipeline in a list: ; always · && after success · || after failure observe - Player lineuntrusted text - ≤ 64 KiB?before history - Bounded historylast 100 lines - Lexquotes · variablespipes · redirection - Parsestages + redirectshape checks - Expandvirtual globstoken + argument budgets + Player lineuntrusted text + ≤ 64 KiB?before history + Bounded historylast 100 lines + Split lists; && || · valid? + Lex one pipelinequotes · variables · $?pipes · redirection + Parsestages + redirectshape checks + Expandvirtual globstoken + argument budgets Preflightcomposition + limits Read inputprior output or VFS @@ -46,9 +49,9 @@ Outputwithin limit? Redirect?virtual only - Virtual writereplace or append + Virtual write> empties before the stagerestored if it fails Next stage?bounded loop - Execution resultoutput + metadata + Pipeline resultoutput + metadataexit status for $? Validatetext + observations @@ -58,7 +61,7 @@ Processesvirtual PID state Archiveslogical payload - + Invariant: no host shell, host path lookup, or external fallback Unsupported names stop at the Go dispatcher. diff --git a/docs/technical/sandbox-and-safety.md b/docs/technical/sandbox-and-safety.md index 6260113..323a041 100644 --- a/docs/technical/sandbox-and-safety.md +++ b/docs/technical/sandbox-and-safety.md @@ -59,7 +59,7 @@ Editable source: [`command-execution-pipeline.mmd`](diagrams/command-execution-p 1. **Bound input:** reject a command line over 64 KiB before adding it to the attempt's 100-entry history. 2. **Split lists:** separate unquoted `;`, `&&`, and `||` into pipelines and check every pipeline's syntax before the first runs. Each pipeline then passes through the remaining stages only when its turn comes, so expansion sees earlier `cd` and `export` effects. -3. **Lex:** reject unquoted shell syntax outside the teaching subset (background jobs, subshells, substitutions, special parameters, and file-descriptor redirection), then recognize words, quotes, escapes, comments, variables, pipes, and `<`, `>`, or `>>`. Interactive and script lines share these rules. Expansion reads only the sandbox environment. +3. **Lex:** reject unquoted shell syntax outside the teaching subset (background jobs, subshells, substitutions, special parameters, and file-descriptor redirection), then recognize words, quotes, escapes, comments, variables, `$?`, pipes, and `<`, `>`, or `>>`. Interactive and script lines share these rules. Expansion reads only the sandbox environment and the attempt's last exit status. 4. **Parse:** build pipeline stages and attach at most one input and output redirection to each stage. 5. **Expand:** resolve eligible globs against the virtual filesystem and enforce expanded token and argument budgets. 6. **Preflight compositions:** reject unsupported interactive-editor or script placement before an earlier pipeline stage can mutate state. @@ -80,7 +80,7 @@ One `sandbox.Sandbox` owns: | --- | --- | --- | | Files and directories | Normalized absolute paths to typed entries with content, mode, and owner; the virtual `HOME` directory always exists | Attempt only | | Working directory | Virtual absolute path | Attempt only; child scripts restore caller scope | -| Environment | String map initialized with virtual `HOME` and `USER` | Attempt only; exported child-script values restore on return | +| Environment | String map initialized with virtual `HOME` and `USER`, plus the last exit status read by `$?` | Attempt only; exported child-script values restore on return, and a script's status becomes its caller's `$?` | | Processes | Mission-provided PID map and running flags | Attempt only; no host PID is visible or signalable | | Archives | Logical archive metadata and payload entries | Attempt only; kept synchronized with virtual path mutations | | History | Last 100 accepted command lines | Attempt only | diff --git a/internal/sandbox/command_list.go b/internal/sandbox/command_list.go index 2add081..2ae3361 100644 --- a/internal/sandbox/command_list.go +++ b/internal/sandbox/command_list.go @@ -107,15 +107,17 @@ func splitCommandList(line string) ([]commandListItem, error) { // returned error is the last pipeline's failure, if any. func (s *Sandbox) executeLine(line string, context *executionContext, allowInteractive bool) (Result, error) { items, err := splitCommandList(line) + if err == nil && len(items) > 1 { + err = s.preflightCommandList(items, allowInteractive) + } if err != nil { - return Result{}, err + // Like sh, a syntax error runs nothing and sets $? to 2. + s.lastStatus = statusTrouble + return Result{}, withExitStatus(statusTrouble, err) } if len(items) == 1 { return s.executePipeline(items[0].line, context, allowInteractive) } - if err := s.preflightCommandList(items, allowInteractive); err != nil { - return Result{}, err - } result := Result{list: true} var output strings.Builder @@ -179,7 +181,7 @@ func (s *Sandbox) preflightCommandList(items []commandListItem, allowInteractive if err := validateShellSyntax(item.line); err != nil { return err } - tokens, err := lex(item.line, s.Env) + tokens, err := lex(item.line, s.Env, s.lastStatus) if err != nil { return err } diff --git a/internal/sandbox/commands_script.go b/internal/sandbox/commands_script.go index b1bd7d2..9212101 100644 --- a/internal/sandbox/commands_script.go +++ b/internal/sandbox/commands_script.go @@ -49,22 +49,27 @@ func (s *Sandbox) cmdExecutableScript(context *executionContext, args []string, resolved := s.Resolve(args[0]) entry, exists := s.FS.Entry(resolved) if !exists { - return "", fmt.Errorf("%s: no such file or directory", args[0]) + return "", withExitStatus(statusCommandNotFound, fmt.Errorf("%s: no such file or directory", args[0])) } if entry.Kind != Regular { - return "", fmt.Errorf("%s: is a directory", args[0]) + return "", withExitStatus(statusCannotExecute, fmt.Errorf("%s: is a directory", args[0])) } if entry.Mode&0o111 == 0 { - return "", fmt.Errorf("%s: permission denied; set an executable mode with chmod", args[0]) + return "", withExitStatus(statusCannotExecute, fmt.Errorf("%s: permission denied; set an executable mode with chmod", args[0])) } return s.executeScript(context, resolved, true) } func (s *Sandbox) executeScript(context *executionContext, scriptPath string, requireShebang bool) (string, error) { - content, err := s.FS.ReadFile(scriptPath) - if err != nil { - return "", err + // As in sh, a missing script exits 127 and one that cannot run exits 126. + entry, exists := s.FS.Entry(scriptPath) + if !exists { + return "", withExitStatus(statusCommandNotFound, fmt.Errorf("%s: no such file", scriptPath)) + } + if entry.Kind != Regular { + return "", withExitStatus(statusCannotExecute, fmt.Errorf("%s: is a directory", scriptPath)) } + content := entry.Content if len(content) > maxScriptBytes { return "", fmt.Errorf("%s: script exceeds the %d KiB limit", scriptPath, maxScriptBytes/1024) } @@ -75,7 +80,7 @@ func (s *Sandbox) executeScript(context *executionContext, scriptPath string, re if requireShebang { firstLine := strings.TrimSuffix(lines[0], "\r") if firstLine != "#!/bin/sh" && firstLine != "#!/usr/bin/env sh" { - return "", fmt.Errorf("%s: executable scripts require #!/bin/sh or #!/usr/bin/env sh", scriptPath) + return "", withExitStatus(statusCannotExecute, fmt.Errorf("%s: executable scripts require #!/bin/sh or #!/usr/bin/env sh", scriptPath)) } } if len(context.scriptStack) >= maxScriptDepth { @@ -94,6 +99,8 @@ func (s *Sandbox) executeScript(context *executionContext, scriptPath string, re s.Env = savedEnv }() + // A script runs in a new shell, where $? starts at 0. + s.lastStatus = 0 var output strings.Builder statusFailed := false for index, rawLine := range lines { diff --git a/internal/sandbox/commands_script_test.go b/internal/sandbox/commands_script_test.go index af82f76..a6e79af 100644 --- a/internal/sandbox/commands_script_test.go +++ b/internal/sandbox/commands_script_test.go @@ -328,7 +328,7 @@ func TestShRejectsUnsupportedShellLanguage(t *testing.T) { {name: "backticks", line: "echo `pwd`", want: "command substitution"}, {name: "substitution", line: "echo $(pwd)", want: "command substitution"}, {name: "quoted substitution", line: `echo "$(pwd)"`, want: "command substitution"}, - {name: "special parameter", line: "echo $?", want: "special parameters"}, + {name: "special parameter", line: "echo $$", want: "special parameters"}, {name: "positional parameter", line: "echo ${1}", want: "special parameters"}, {name: "if keyword", line: "if echo yes", want: "keyword"}, {name: "for keyword", line: "for item", want: "keyword"}, diff --git a/internal/sandbox/dispatch.go b/internal/sandbox/dispatch.go index b0bcfd5..4ec4100 100644 --- a/internal/sandbox/dispatch.go +++ b/internal/sandbox/dispatch.go @@ -40,7 +40,8 @@ func (s *Sandbox) run(context *executionContext, args []string, stdin string) (o case "cd": return s.cmdCD(args[1:]) case "ls": - return s.cmdLS(args[1:]) + output, err := s.cmdLS(args[1:]) + return output, withExitStatus(statusTrouble, err) case "mkdir": return s.cmdMkdir(args[1:]) case "touch": @@ -60,7 +61,8 @@ func (s *Sandbox) run(context *executionContext, args []string, stdin string) (o case "history": return s.cmdHistory(args[1:]) case "grep": - return s.cmdGrep(args[1:], stdin) + output, err := s.cmdGrep(args[1:], stdin) + return output, withExitStatus(statusTrouble, err) case "find": return s.cmdFind(context, args[1:]) case "sh": @@ -86,7 +88,8 @@ func (s *Sandbox) run(context *executionContext, args []string, stdin string) (o case "env": return s.cmdEnv(args[1:]) case "sort": - return s.cmdSort(args[1:], stdin) + output, err := s.cmdSort(args[1:], stdin) + return output, withExitStatus(statusTrouble, err) case "uniq": return s.cmdUniq(args[1:], stdin) case "wc": @@ -114,6 +117,6 @@ func (s *Sandbox) run(context *executionContext, args []string, stdin string) (o case "help", "man": return shellHelp(args[1:]) default: - return "", fmt.Errorf("command not available in this lab; type help to see supported commands") + return "", withExitStatus(statusCommandNotFound, fmt.Errorf("command not available in this lab; type help to see supported commands")) } } diff --git a/internal/sandbox/exit_status.go b/internal/sandbox/exit_status.go new file mode 100644 index 0000000..ab4930a --- /dev/null +++ b/internal/sandbox/exit_status.go @@ -0,0 +1,47 @@ +package sandbox + +import "errors" + +// Exit statuses the lab models beyond 0 and 1. Any other failure exits with +// 1, as most GNU tools do, and $? reports these values like a real shell. +const ( + // statusTrouble is sh's status for a syntax error and the documented + // status of ls, grep, and sort for errors such as a missing file. + statusTrouble = 2 + // statusCannotExecute is for a script that exists but cannot run. + statusCannotExecute = 126 + // statusCommandNotFound is for a name the lab does not provide. + statusCommandNotFound = 127 +) + +// exitStatusError gives a failure its shell exit status. +type exitStatusError struct { + status int + err error +} + +func (e exitStatusError) Error() string { return e.err.Error() } + +func (e exitStatusError) Unwrap() error { return e.err } + +// withExitStatus marks err with status. A silent failing status, such as grep +// finding no match, keeps its status of 1. +func withExitStatus(status int, err error) error { + if err == nil || errors.Is(err, errFailureStatus) { + return err + } + return exitStatusError{status: status, err: err} +} + +// exitStatus is the shell status of a pipeline's outcome: 0 on success, the +// outermost marked status of a failure, or 1. +func exitStatus(err error, statusFailed bool) int { + var marked exitStatusError + switch { + case errors.As(err, &marked): + return marked.status + case err != nil || statusFailed: + return 1 + } + return 0 +} diff --git a/internal/sandbox/exit_status_test.go b/internal/sandbox/exit_status_test.go new file mode 100644 index 0000000..052356b --- /dev/null +++ b/internal/sandbox/exit_status_test.go @@ -0,0 +1,95 @@ +package sandbox + +import "testing" + +func TestExitStatusMatchesTheShell(t *testing.T) { + tests := []struct { + name string + line string + want string + }{ + {name: "success", line: "true; echo $?", want: "0\n"}, + {name: "false", line: "false; echo $?", want: "1\n"}, + {name: "ordinary failure", line: "cat missing.log; echo $?", want: "1\n"}, + {name: "ls trouble", line: "ls /missing; echo $?", want: "2\n"}, + {name: "grep match", line: "grep -q ERROR events.log; echo $?", want: "0\n"}, + {name: "grep no match", line: "grep PANIC events.log; echo $?", want: "1\n"}, + {name: "grep trouble", line: "grep ERROR missing.log; echo $?", want: "2\n"}, + {name: "sort trouble", line: "sort missing.log; echo $?", want: "2\n"}, + {name: "unknown command", line: "deploy-everything; echo $?", want: "127\n"}, + {name: "missing script", line: "sh missing.sh; echo $?", want: "127\n"}, + {name: "missing executable", line: "./missing.sh; echo $?", want: "127\n"}, + {name: "directory as script", line: "sh /work; echo $?", want: "126\n"}, + {name: "pipeline is its last stage", line: "grep PANIC events.log | wc -l; echo $?", want: "0\n0\n"}, + {name: "skipped command keeps the status", line: "false && echo never; echo $?", want: "1\n"}, + {name: "status updates after each command", line: "false; true; echo $?", want: "0\n"}, + {name: "braces", line: "false; echo ${?}", want: "1\n"}, + {name: "double quotes", line: `false; echo "status=$?"`, want: "status=1\n"}, + {name: "single quotes are literal", line: `false; echo '$?'`, want: "$?\n"}, + {name: "escaped is literal", line: `false; echo \$?`, want: "$?\n"}, + {name: "starts at zero", line: "echo $?", want: "0\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + box := testSandbox(t) + result, err := box.Execute(test.line) + if err != nil { + t.Fatalf("Execute(%q) error = %v", test.line, err) + } + if result.Output != test.want { + t.Errorf("Execute(%q) output = %q, want %q", test.line, result.Output, test.want) + } + }) + } +} + +func TestExitStatusPersistsAcrossLines(t *testing.T) { + box := testSandbox(t) + writeTestScript(t, box, "plain.sh", "echo hi\n", 0o644) + steps := []struct{ line, want string }{ + {line: "ls /missing"}, + {line: "echo $?", want: "2\n"}, + {line: "echo $?", want: "0\n"}, + {line: "false"}, + {line: ""}, + {line: " # a comment"}, + {line: "echo $?", want: "1\n"}, + {line: "echo 'unterminated"}, + {line: "echo $?", want: "2\n"}, + {line: "echo a &&"}, + {line: "echo $?", want: "2\n"}, + {line: "echo $(pwd)"}, + {line: "echo $?", want: "2\n"}, + {line: "./plain.sh"}, + {line: "echo $?", want: "126\n"}, + } + for _, step := range steps { + result, err := box.Execute(step.line) + if step.want == "" { + continue + } + if err != nil || result.Output != step.want { + t.Fatalf("Execute(%q) = %q, %v; want %q", step.line, result.Output, err, step.want) + } + } +} + +func TestScriptsHaveTheirOwnExitStatus(t *testing.T) { + box := testSandbox(t) + writeTestScript(t, box, "inner-status.sh", "false\necho \"inside=$?\"\n", 0o644) + writeTestScript(t, box, "fresh.sh", "echo \"start=$?\"\n", 0o644) + writeTestScript(t, box, "fails.sh", "echo start\nls /missing\n", 0o644) + writeTestScript(t, box, "last.sh", "echo checking\ngrep -q PANIC events.log\n", 0o644) + steps := []struct{ line, want string }{ + {line: "sh inner-status.sh", want: "inside=1\n"}, + {line: "false; sh fresh.sh", want: "start=0\n"}, + {line: "sh fails.sh; echo $?", want: "2\n"}, + {line: "sh last.sh; echo $?", want: "checking\n1\n"}, + } + for _, step := range steps { + result, err := box.Execute(step.line) + if err != nil || result.Output != step.want { + t.Fatalf("Execute(%q) = %q, %v; want %q", step.line, result.Output, err, step.want) + } + } +} diff --git a/internal/sandbox/lexer.go b/internal/sandbox/lexer.go index 26564e6..7477e8b 100644 --- a/internal/sandbox/lexer.go +++ b/internal/sandbox/lexer.go @@ -2,6 +2,7 @@ package sandbox import ( "fmt" + "strconv" "strings" "unicode" "unicode/utf8" @@ -15,7 +16,9 @@ type token struct { glob bool } -func lex(line string, env map[string]string) ([]token, error) { +// lex splits one pipeline into tokens, expanding variables from env and $? +// from status. +func lex(line string, env map[string]string, status int) ([]token, error) { var tokens []token var current strings.Builder var quote rune @@ -101,7 +104,7 @@ func lex(line string, env map[string]string) ([]token, error) { } continue case char == '$': - value, consumed := expandVariable(runes[i:], env) + value, consumed := expandVariable(runes[i:], env, status) if err := writeString(value); err != nil { return nil, err } @@ -143,7 +146,7 @@ func lex(line string, env map[string]string) ([]token, error) { continue } if quote == '"' && char == '$' { - value, consumed := expandVariable(runes[i:], env) + value, consumed := expandVariable(runes[i:], env, status) if err := writeString(value); err != nil { return nil, err } @@ -161,13 +164,19 @@ func lex(line string, env map[string]string) ([]token, error) { return tokens, nil } -func expandVariable(input []rune, env map[string]string) (string, int) { +func expandVariable(input []rune, env map[string]string, status int) (string, int) { if len(input) < 2 { return "$", 0 } + if input[1] == '?' { + return strconv.Itoa(status), 1 + } if input[1] == '{' { for i := 2; i < len(input); i++ { if input[i] == '}' { + if name := string(input[2:i]); name == "?" { + return strconv.Itoa(status), i + } return env[string(input[2:i])], i } } diff --git a/internal/sandbox/manuals.go b/internal/sandbox/manuals.go index 2c7b013..a08eb31 100644 --- a/internal/sandbox/manuals.go +++ b/internal/sandbox/manuals.go @@ -20,6 +20,7 @@ func shellHelp(args []string) (string, error) { return "Available lab commands:\n " + strings.Join(commands, " ") + "\n\nShell features: pipelines (|), input (<), output (>), and append (>>) redirection." + "\nCommand lists: a; b runs both, a && b runs b only if a succeeds, and a || b only if a fails." + + "\necho $? prints the last exit status: 0 success, 1 failure, 2 syntax or ls/grep/sort trouble, 126 cannot run, 127 not found." + "\nNot supported: background jobs (&), 2> and other descriptor redirection, and $(...) substitution." + fmt.Sprintf("\nSandbox limits: %d KiB per command line; %d KiB expanded tokens; %d expanded arguments; %d pipeline stages; %d command dispatches; %d MiB per file and command output; %d MiB filesystem content and %d MiB archive payload; %d filesystem entries and %d archive entries.", maxCommandLineBytes/1024, maxExpandedTokenBytes/1024, maxExpandedArguments, maxPipelineStages, maxExecutionDispatchSteps, diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 4e91fa2..a925ae1 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -34,6 +34,9 @@ type Sandbox struct { Processes map[int]*Process Archives map[string]Archive History []string + + // lastStatus is the exit status of the last pipeline that ran, read by $?. + lastStatus int } func New(setup mission.Setup, startDir string) (*Sandbox, error) { diff --git a/internal/sandbox/shell.go b/internal/sandbox/shell.go index 5421dd6..8eb0798 100644 --- a/internal/sandbox/shell.go +++ b/internal/sandbox/shell.go @@ -104,25 +104,33 @@ func (s *Sandbox) Execute(line string) (Result, error) { // executePipeline runs one pipeline: validate, lex, parse, expand, open // output redirections, and run each stage. statusFailed in the result reports -// the last stage's silent failing status. -func (s *Sandbox) executePipeline(line string, context *executionContext, allowInteractive bool) (Result, error) { +// the last stage's silent failing status, and the pipeline's exit status +// becomes the next $?. A blank or comment-only line leaves $? unchanged. +func (s *Sandbox) executePipeline(line string, context *executionContext, allowInteractive bool) (result Result, err error) { + ran := true + defer func() { + if ran { + s.lastStatus = exitStatus(err, result.statusFailed) + } + }() if err := validateShellSyntax(line); err != nil { - return Result{}, err + return Result{}, withExitStatus(statusTrouble, err) } - tokens, err := lex(line, s.Env) + tokens, err := lex(line, s.Env, s.lastStatus) if err != nil { - return Result{}, err + return Result{}, withExitStatus(statusTrouble, err) } parsed, err := parseCommandLine(tokens) if err != nil { - return Result{}, err + return Result{}, withExitStatus(statusTrouble, err) } if len(parsed.stages) == 0 { + ran = false return Result{}, nil } if !allowInteractive { if err := validateScriptCommands(parsed); err != nil { - return Result{}, err + return Result{}, withExitStatus(statusTrouble, err) } } expanded, err := s.expandCommandLine(parsed) diff --git a/internal/sandbox/syntax.go b/internal/sandbox/syntax.go index 2fcbe81..e526d5d 100644 --- a/internal/sandbox/syntax.go +++ b/internal/sandbox/syntax.go @@ -117,10 +117,11 @@ func isUnsupportedShellParameter(input []rune) bool { if len(input) == 0 { return false } - if unicode.IsDigit(input[0]) || strings.ContainsRune("?$!#*@-", input[0]) { + // $? is supported; the lexer expands it to the last exit status. + if unicode.IsDigit(input[0]) || strings.ContainsRune("$!#*@-", input[0]) { return true } - return len(input) > 1 && input[0] == '{' && (unicode.IsDigit(input[1]) || strings.ContainsRune("?$!#*@-", input[1])) + return len(input) > 1 && input[0] == '{' && (unicode.IsDigit(input[1]) || strings.ContainsRune("$!#*@-", input[1])) } func unsupportedParameterError(input []rune) error { diff --git a/internal/sandbox/syntax_test.go b/internal/sandbox/syntax_test.go index 8b28d2d..9a185fe 100644 --- a/internal/sandbox/syntax_test.go +++ b/internal/sandbox/syntax_test.go @@ -27,7 +27,7 @@ func TestInteractiveLinesRejectUnsupportedShellSyntaxBeforeRunning(t *testing.T) {name: "numbered input", line: "touch /out/first.txt 0