-
Notifications
You must be signed in to change notification settings - Fork 1
feat(interp): add global 10 MiB stdout cap to Runner.Run #161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
438e9b5
9f6b8e2
7385b3d
dd6b6cc
2151ff4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ import ( | |
| "io/fs" | ||
| "os" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| "mvdan.cc/sh/v3/expand" | ||
| "mvdan.cc/sh/v3/syntax" | ||
|
|
@@ -49,6 +50,12 @@ func (r *Runner) updateExpandOpts() { | |
| // commands that produce unbounded output. | ||
| const maxCmdSubstOutput = 1 << 20 // 1 MiB | ||
|
|
||
| // maxStdoutBytes is the maximum number of bytes a script can write to stdout | ||
| // before further output is silently discarded. This caps total script output | ||
| // to prevent memory exhaustion from runaway commands (e.g. infinite loops | ||
| // writing to stdout). | ||
| const maxStdoutBytes = 10 * 1024 * 1024 // 10 MiB | ||
|
|
||
| // MaxGlobReadDirCalls is the maximum number of ReadDirForGlob invocations | ||
| // allowed per Run() call. This prevents memory exhaustion from scripts that | ||
| // trigger an excessive number of glob expansions (e.g. millions of unquoted | ||
|
|
@@ -130,14 +137,27 @@ func catShortcutArg(stmt *syntax.Stmt) *syntax.Word { | |
| } | ||
|
|
||
| // limitWriter wraps a writer and stops writing after limit bytes. | ||
| // When the limit is exceeded, exceeded is set to true and further writes | ||
| // are silently discarded so that callers do not see spurious short-write | ||
| // errors mid-execution. The exceeded flag can be checked after execution | ||
| // via isExceeded to surface the event as an error. | ||
| // | ||
| // limitWriter is safe for concurrent use: the mutex serialises writes so | ||
| // that the byte counter and exceeded flag are always consistent, even when | ||
| // background goroutines write to the same writer concurrently. | ||
| type limitWriter struct { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: let's add a comment stating this is not thread-safe object (it is passed by pointer today, and the thread-safety depends on the caller discipline: overwriting the stdout field
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Sonnet 4.6] Fixed in 2151ff4 — made |
||
| w io.Writer | ||
| limit int64 | ||
| n int64 | ||
| mu sync.Mutex | ||
| w io.Writer | ||
| limit int64 | ||
| n int64 | ||
| exceeded bool | ||
| } | ||
|
|
||
| func (lw *limitWriter) Write(p []byte) (int, error) { | ||
| lw.mu.Lock() | ||
| defer lw.mu.Unlock() | ||
| if lw.n >= lw.limit { | ||
| lw.exceeded = true | ||
| return len(p), nil // silently discard excess | ||
| } | ||
| remaining := lw.limit - lw.n | ||
|
|
@@ -146,13 +166,22 @@ func (lw *limitWriter) Write(p []byte) (int, error) { | |
| return int(remaining), err | ||
| } | ||
| lw.n = lw.limit | ||
| lw.exceeded = true | ||
| return len(p), nil // report all bytes consumed to avoid short-write errors | ||
| } | ||
| n, err := lw.w.Write(p) | ||
| lw.n += int64(n) | ||
| return n, err | ||
| } | ||
|
|
||
| // isExceeded reports whether any write has exceeded the byte limit. | ||
| // It is safe to call concurrently with Write. | ||
| func (lw *limitWriter) isExceeded() bool { | ||
| lw.mu.Lock() | ||
| defer lw.mu.Unlock() | ||
| return lw.exceeded | ||
| } | ||
|
|
||
| func (r *Runner) expandErr(err error) { | ||
| if err == nil { | ||
| return | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,9 @@ | |
| package tests_test | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
|
|
@@ -16,10 +18,139 @@ import ( | |
|
|
||
| "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" | ||
| ) | ||
|
|
||
| // --- Memory limits: output capping --- | ||
|
|
||
| // TestGlobalStdoutCapReturnsError verifies that Run returns ErrOutputLimitExceeded | ||
| // when a script exceeds the 10 MiB stdout cap. The script runs to completion | ||
| // but partial output (up to the limit) is still delivered, and the caller | ||
| // receives a well-defined error rather than a silent truncation. | ||
| func TestGlobalStdoutCapReturnsError(t *testing.T) { | ||
| dir := t.TempDir() | ||
|
|
||
| // Create a file of exactly 1 MiB. | ||
| content := strings.Repeat("A", 1<<20) | ||
| require.NoError(t, os.WriteFile(filepath.Join(dir, "mb.txt"), []byte(content), 0644)) | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| // cat the file 11 times — produces 11 MiB, exceeding the 10 MiB cap. | ||
| script := `for i in 1 2 3 4 5 6 7 8 9 10 11; do cat mb.txt; done` | ||
| var outBuf bytes.Buffer | ||
| runner, err := interp.New( | ||
| interp.StdIO(nil, &outBuf, nil), | ||
| interp.AllowedPaths([]string{dir}), | ||
| interpoption.AllowAllCommands().(interp.RunnerOption), | ||
| ) | ||
| require.NoError(t, err) | ||
| defer runner.Close() | ||
| runner.Dir = dir | ||
|
|
||
| prog, err := syntax.NewParser().Parse(strings.NewReader(script), "test") | ||
| require.NoError(t, err) | ||
|
|
||
| runErr := runner.Run(ctx, prog) | ||
| assert.ErrorIs(t, runErr, interp.ErrOutputLimitExceeded, | ||
| "Run must return ErrOutputLimitExceeded when stdout cap is exceeded") | ||
| // Output up to the cap must still be delivered. | ||
| assert.LessOrEqual(t, outBuf.Len(), 10*1024*1024, | ||
| "stdout must not exceed 10 MiB; got %d bytes", outBuf.Len()) | ||
| assert.Greater(t, outBuf.Len(), 0, "expected non-empty stdout before cap") | ||
| } | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 — Missing: test for multiple The current test only exercises a single Also missing: a test for a script that both exits non-zero and exceeds the cap, to document which error takes priority.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9f6b8e2. Added |
||
|
|
||
| // TestGlobalStdoutCapMultipleRuns verifies that repeated Run() calls on the | ||
| // same Runner without Reset() do not double-wrap the stdout writer. The first | ||
| // call must not leave r.stdout pointing at the limitWriter, so the second call | ||
| // starts with a fresh 10 MiB budget rather than inheriting the first call's | ||
| // byte counter. | ||
| func TestGlobalStdoutCapMultipleRuns(t *testing.T) { | ||
| dir := t.TempDir() | ||
| content := strings.Repeat("A", 1<<20) // 1 MiB | ||
| require.NoError(t, os.WriteFile(filepath.Join(dir, "mb.txt"), []byte(content), 0644)) | ||
|
|
||
| var outBuf bytes.Buffer | ||
| runner, err := interp.New( | ||
| interp.StdIO(nil, &outBuf, nil), | ||
| interp.AllowedPaths([]string{dir}), | ||
| interpoption.AllowAllCommands().(interp.RunnerOption), | ||
| ) | ||
| require.NoError(t, err) | ||
| defer runner.Close() | ||
| runner.Dir = dir | ||
|
|
||
| parse := func(script string) *syntax.File { | ||
| t.Helper() | ||
| prog, parseErr := syntax.NewParser().Parse(strings.NewReader(script), "test") | ||
| require.NoError(t, parseErr) | ||
| return prog | ||
| } | ||
|
|
||
| ctx := context.Background() | ||
|
|
||
| // First call: write 9 MiB — just under the cap. Must succeed. | ||
| outBuf.Reset() | ||
| ctx1, cancel1 := context.WithTimeout(ctx, 10*time.Second) | ||
| defer cancel1() | ||
| err = runner.Run(ctx1, parse(`for i in 1 2 3 4 5 6 7 8 9; do cat mb.txt; done`)) | ||
| assert.NoError(t, err, "first run (9 MiB) must not exceed cap") | ||
| assert.Equal(t, 9<<20, outBuf.Len(), "first run must deliver exactly 9 MiB") | ||
|
|
||
| // Second call: write another 9 MiB. If r.stdout was not restored, the | ||
| // wrapped limitWriter from call 1 already has 9 MiB counted and would | ||
| // silently drop all output here — returning no error. A fresh budget means | ||
| // this call also succeeds with 9 MiB delivered. | ||
| outBuf.Reset() | ||
| ctx2, cancel2 := context.WithTimeout(ctx, 10*time.Second) | ||
| defer cancel2() | ||
| err = runner.Run(ctx2, parse(`for i in 1 2 3 4 5 6 7 8 9; do cat mb.txt; done`)) | ||
| assert.NoError(t, err, "second run (9 MiB) must not exceed cap") | ||
| assert.Equal(t, 9<<20, outBuf.Len(), "second run must deliver exactly 9 MiB (fresh cap)") | ||
| } | ||
|
|
||
| // TestGlobalStdoutCapPrecedenceOverExitCode verifies that ErrOutputLimitExceeded | ||
| // takes precedence over a non-zero exit code when both occur in the same Run() | ||
| // call. Fatal handler errors (r.exit.err) still take precedence over the cap | ||
| // per the ordering in Run(), but that path is not easily triggerable from a | ||
| // script-level test. | ||
| func TestGlobalStdoutCapPrecedenceOverExitCode(t *testing.T) { | ||
| dir := t.TempDir() | ||
| content := strings.Repeat("A", 1<<20) // 1 MiB | ||
| require.NoError(t, os.WriteFile(filepath.Join(dir, "mb.txt"), []byte(content), 0644)) | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| var outBuf bytes.Buffer | ||
| runner, err := interp.New( | ||
| interp.StdIO(nil, &outBuf, nil), | ||
| interp.AllowedPaths([]string{dir}), | ||
| interpoption.AllowAllCommands().(interp.RunnerOption), | ||
| ) | ||
| require.NoError(t, err) | ||
| defer runner.Close() | ||
| runner.Dir = dir | ||
|
|
||
| // Exceed the cap then exit non-zero. ErrOutputLimitExceeded must be returned, | ||
| // not ExitStatus(1). | ||
| prog, parseErr := syntax.NewParser().Parse(strings.NewReader( | ||
| `for i in 1 2 3 4 5 6 7 8 9 10 11; do cat mb.txt; done; exit 1`, | ||
| ), "test") | ||
| require.NoError(t, parseErr) | ||
|
|
||
| runErr := runner.Run(ctx, prog) | ||
| assert.ErrorIs(t, runErr, interp.ErrOutputLimitExceeded, | ||
| "ErrOutputLimitExceeded must take precedence over a non-zero exit code") | ||
| var es interp.ExitStatus | ||
| assert.False(t, errors.As(runErr, &es), | ||
| "must not return ExitStatus when stdout cap was exceeded") | ||
| } | ||
|
|
||
| func TestCmdSubstOutputCapped(t *testing.T) { | ||
| // Generate output exceeding 1 MiB inside command substitution. | ||
| // The output should be truncated, not cause OOM. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1 —
r.stdoutis never restored afterRun()returns.After this line,
r.stdoutpoints tostdoutCap. The nextRun()call (withoutReset()) will do:The outer
limitWriterhas a freshn=0, so the per-call check works. But all writes flow throughstdoutCap(from call 1) whosenstill reflects call 1's bytes. If call 1 wrote 9 MiB, call 2 can only write 1 MiB before silent discard — andstdoutCap2.exceededstaysfalse, soRun()returnsnilwith truncated output.Reset()between calls does restorer.origStdoutcorrectly (line 399). ButRun()is documented to be callable multiple times incrementally withoutReset().Fix:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 9f6b8e2. Added
prevStdout := r.stdoutbefore wrapping and adefer func() { r.stdout = prevStdout }()so the writer is always restored — including through the panic recovery path.