Skip to content
Closed
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
44 changes: 41 additions & 3 deletions pkg/unikontainers/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,46 @@ func rmMultipleDirs(prefixPath string, dirs []string) error {
return nil
}

// maxHookOutput caps how much stdout/stderr urunc retains from an OCI hook.
// The captured output is only used to build error messages, so a modest cap is
// plenty while preventing a hook that streams unbounded data from exhausting
// memory (see #797).
const maxHookOutput = 1 << 20 // 1 MiB per stream

// limitedBuffer is an io.Writer that retains at most maxBytes bytes and discards
// anything beyond that. It always reports the full slice as written so the
// writing process is never blocked or killed with a short-write/EPIPE error.
type limitedBuffer struct {
buf bytes.Buffer
maxBytes int
truncated bool
}

func (b *limitedBuffer) Write(p []byte) (int, error) {
if remaining := b.maxBytes - b.buf.Len(); remaining > 0 {
if len(p) > remaining {
b.buf.Write(p[:remaining])
b.truncated = true
} else {
b.buf.Write(p)
}
} else if len(p) > 0 {
b.truncated = true
}
return len(p), nil
}

// String returns the captured output, appending a marker when it was truncated.
func (b *limitedBuffer) String() string {
if b.truncated {
return b.buf.String() + "... [output truncated]"
}
return b.buf.String()
}

func executeHook(hook specs.Hook, state []byte) error {
var stdout, stderr bytes.Buffer
stdout := &limitedBuffer{maxBytes: maxHookOutput}
stderr := &limitedBuffer{maxBytes: maxHookOutput}
var cancel context.CancelFunc
ctx := context.Background()

Expand All @@ -327,8 +365,8 @@ func executeHook(hook specs.Hook, state []byte) error {
cmd := exec.CommandContext(ctx, hook.Path, args...) // nolint:gosec
cmd.Env = hook.Env
cmd.Stdin = bytes.NewReader(state)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Stdout = stdout
cmd.Stderr = stderr

err := cmd.Run()
if err != nil {
Expand Down
49 changes: 49 additions & 0 deletions pkg/unikontainers/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,52 @@ func TestLoadSpec(t *testing.T) {
assert.Contains(t, err.Error(), "failed to parse specification json", "Expected specific error message")
})
}

func TestLimitedBufferCapsOutput(t *testing.T) {
t.Parallel()

t.Run("under limit keeps everything", func(t *testing.T) {
t.Parallel()
b := &limitedBuffer{maxBytes: 100}
n, err := b.Write([]byte("hello"))
assert.NoError(t, err)
assert.Equal(t, 5, n)
assert.Equal(t, "hello", b.String())
assert.False(t, b.truncated)
})

t.Run("exact limit is not truncated", func(t *testing.T) {
t.Parallel()
b := &limitedBuffer{maxBytes: 3}
_, err := b.Write([]byte("abc"))
assert.NoError(t, err)
assert.Equal(t, "abc", b.String())
assert.False(t, b.truncated)
})

t.Run("single oversized write is capped but reports full length", func(t *testing.T) {
t.Parallel()
b := &limitedBuffer{maxBytes: 4}
payload := []byte("abcdefghij") // 10 bytes
n, err := b.Write(payload)
assert.NoError(t, err)
// Must report the full slice as written, else exec's io.Copy fails with
// ErrShortWrite and the hook is treated as broken.
assert.Equal(t, len(payload), n)
assert.Equal(t, 4, b.buf.Len())
assert.True(t, b.truncated)
assert.Equal(t, "abcd... [output truncated]", b.String())
})

t.Run("many writes stay bounded at the cap", func(t *testing.T) {
t.Parallel()
b := &limitedBuffer{maxBytes: 5}
for i := 0; i < 1000; i++ {
n, err := b.Write([]byte("xxxxxxxxxx")) // 10 bytes each
assert.NoError(t, err)
assert.Equal(t, 10, n)
}
assert.Equal(t, 5, b.buf.Len())
assert.True(t, b.truncated)
})
}