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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ Sed selects the longest regex match at the leftmost position, including for in-p

For simple commands, rshell expands only enough words to identify the command, then applies command, elevation, and mode policy before expanding the remaining arguments, inline assignments, or redirects. A rejected command therefore cannot trigger command substitutions or redirect side effects through its unused arguments. Once a command is authorized, redirects retain normal shell semantics: they are established before execution and remain effective even if the command later exits nonzero.

Shell expansion is bounded independently of the execution timeout: a command may receive at most 16,384 expanded arguments totaling 10 MiB, and one `Run` may produce at most 64 MiB of expanded argument, assignment, redirect, and heredoc text across all loops, subshells, and pipeline stages. Individual variable values remain capped at 1 MiB and heredocs at 10 MiB.
Shell expansion is bounded independently of the execution timeout: a command may receive at most 16,384 expanded arguments totaling 10 MiB, and one `Run` may produce at most 64 MiB of expanded argument, assignment, redirect, and heredoc text across all loops, subshells, and pipeline stages. The argument cap is enforced before an unquoted variable or command substitution is field-split, not after the fields are built. Individual variable values remain capped at 1 MiB and heredocs at 10 MiB. The variables a script holds at once are capped at 1 MiB of values plus 1 MiB of names and fixed per-variable overhead (about 16k variables), shared by every concurrently running subshell and pipeline stage; a subshell that overrides or clears an inherited variable is still charged for the copy its parent holds.

Redirect targets undergo shell expansion and must resolve to exactly one field; ambiguous targets fail before the file is opened. Quoting preserves literal spaces and wildcard characters.

Expand Down
3 changes: 2 additions & 1 deletion SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@ the substituted command.
- ✅ Single quotes: `'literal'`
- ✅ Double quotes: `"with $expansion"`
- ✅ Globbing: `*`, `?`, `[abc]`, `[a-z]`, `[!a]`
- ✅ Bounded expansion: at most 16,384 expanded arguments and 10 MiB of expanded argument text per command; at most 64 MiB of expanded argument, assignment, redirect, and heredoc text per `Run`, shared across loops, subshells, and pipeline stages; the execution context is checked between produced fields
- ✅ Bounded expansion: at most 16,384 expanded arguments and 10 MiB of expanded argument text per command; at most 64 MiB of expanded argument, assignment, redirect, and heredoc text per `Run`, shared across loops, subshells, and pipeline stages; the execution context is checked between produced fields; the argument cap is enforced on an upper bound before an unquoted variable is field-split and before an unquoted command substitution's output is split, so the limit fires before the fields are built
- ✅ Bounded variable storage: each value is capped at 1 MiB; the values a script holds at once are capped at 1 MiB in total, and variable names plus a fixed 64-byte per-variable overhead at a further 1 MiB (about 16k variables), even when the values are empty; both caps are shared by every subshell and pipeline stage that is alive at the same time, and storage is returned when a subshell or stage finishes; overriding or clearing an inherited variable in a subshell does not refund the copy its parent still holds
- ✅ Line continuation: `\` at end of line
- ✅ Comments: `# text`
- ❌ Extended globbing: `@(pat)`, `*(pat)`, etc.
Expand Down
20 changes: 18 additions & 2 deletions interp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ type runnerState struct {

lastExpandExit exitStatus // used to surface exit statuses while expanding fields

// activeFields is the field collector currently expanding a word on this
// runner, if any. cmdSubst uses it to charge the output of unquoted
// command substitutions against the field budget before the expander
// splits it. Subshells do not inherit it.
activeFields *fieldCollector

// startTime is captured once at the beginning of Run() and passed to
// all builtin invocations so they share a consistent time reference.
startTime time.Time
Expand Down Expand Up @@ -569,9 +575,10 @@ func (r *Runner) Reset() {
// count against the user-visible MaxTotalVarsBytes cap. Those values are
// small and bounded; only the variables that a script itself creates or
// modifies should count. ALLOWED_PATHS is operator-configured and
// typically a few hundred bytes, so this is safe.
// typically a few hundred bytes, so this is safe. This also starts the
// run-wide budget that subshells and pipeline stages share.
if ov, ok := r.writeEnv.(*overlayEnviron); ok {
ov.totalBytes = 0
ov.resetAccounting()
}

r.didReset = true
Expand Down Expand Up @@ -1104,6 +1111,15 @@ func (r *Runner) subshell(background bool) *Runner {
return r2
}

// releaseVarStorage returns a finished subshell's variable storage to the
// run-wide budget shared with its parent and siblings. Call it once the
// subshell from [Runner.subshell] has stopped running.
func (r *Runner) releaseVarStorage() {
if ov, ok := r.writeEnv.(*overlayEnviron); ok {
ov.release()
}
}

// classifyRunOutcome maps the error Run() is about to return to a stable,
// low-cardinality enum tag on the run span. The outcome answers "did the
// interpreter do its job", not "did the script exit zero" — any script
Expand Down
2 changes: 1 addition & 1 deletion interp/internal_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ func TestExpandErrStorageExhaustedAborts(t *testing.T) {

// expandEnv.Set wraps the error with the variable name before it reaches
// expandErr, producing "name: variable storage limit exceeded (N bytes total)".
storageErr := fmt.Errorf("UNSET_VAR: %w", &errTotalVarStorageExceeded{total: 1048577})
storageErr := fmt.Errorf("UNSET_VAR: %w", &errTotalVarStorageExceeded{total: varStorage{values: 1048577}})
r.expandErr(storageErr)

assert.True(t, r.exit.exiting, "expandErr must set exiting=true for storage-cap errors so the script aborts")
Expand Down
Loading
Loading