Skip to content

Commit ee2be3d

Browse files
peczenyjclaude
andauthored
fix(ui): clamp column width to avoid makeslice panic on huge -width (#101)
A user-supplied -width propagated unclamped into the side-by-side renderer, where both truncPad (via runewidth.FillRight) and the divider (strings.Repeat) would attempt a multi-gigabyte allocation and panic with "makeslice: len out of range": structalign -diff=side -width=4611686018427387904 ./pkg Clamp the per-side width to maxColWidth (1<<16 cells, far wider than any real terminal) inside truncPad - making the fuzzed function total - and once in renderSideBySide so the divider shares the bound. Found by fuzzing during the ClusterFuzzLite integration (#99): fuzz_trunc_pad crashed within seconds in the OSS-Fuzz container. The bug predates #94 - the old hand-rolled loop panicked identically in strings.Repeat. After the fix the target runs 64k+ executions clean. Closes #100 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f4478e5 commit ee2be3d

2 files changed

Lines changed: 37 additions & 4 deletions

File tree

internal/ui/printer.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,20 +247,23 @@ func (p *Printer) renderSideBySide(a, b string) {
247247
}
248248

249249
sep := " │ "
250+
// Clamp once so the divider's strings.Repeat shares truncPad's bound; an
251+
// unclamped huge Width would panic there with "makeslice: len out of range".
252+
width := min(p.Width, maxColWidth)
250253
// Header and divider must share the exact column geometry of the data
251254
// rows: each side is `width` columns, joined by sep (" │ "). The divider
252255
// mirrors sep as "─┼─" so the ┼ lands directly under every │.
253256
// Pad the header text manually (not via %-*s) so it stays correct even
254257
// when paint wraps it in ANSI escapes, which %-*s would miscount.
255258
fmt.Fprintf(p.Out, " %s%s%s\n", //nolint:errcheck
256-
p.paint(th.Meta, truncPad("current", p.Width)),
259+
p.paint(th.Meta, truncPad("current", width)),
257260
sep,
258261
p.paint(th.Meta, "proposed"))
259262
fmt.Fprintf(p.Out, " %s\n", p.paint(th.Meta, //nolint:errcheck
260-
strings.Repeat("─", p.Width)+"─┼─"+strings.Repeat("─", p.Width)))
263+
strings.Repeat("─", width)+"─┼─"+strings.Repeat("─", width)))
261264
for _, r := range rows {
262-
left := truncPad(r.l, p.Width)
263-
right := truncPad(r.r, p.Width)
265+
left := truncPad(r.l, width)
266+
right := truncPad(r.r, width)
264267
if r.lc != (Style{}) {
265268
left = p.paint(r.lc, left)
266269
}
@@ -371,13 +374,21 @@ func withTypeName(src, name string) string {
371374
return first + "\n" + rest
372375
}
373376

377+
// maxColWidth caps the per-side column width. It is far wider than any real
378+
// terminal and keeps padding allocations bounded: an unclamped huge width
379+
// (e.g. -width=1<<62) would make FillRight/strings.Repeat attempt a
380+
// multi-gigabyte allocation and panic with "makeslice: len out of range".
381+
const maxColWidth = 1 << 16
382+
374383
// truncPad fits s into exactly w display cells: right-padded with spaces when
375384
// shorter, or truncated with a trailing "…" when longer. Width is measured in
376385
// terminal cells via runewidth, so CJK and other wide runes count as two.
386+
// Widths above maxColWidth are clamped.
377387
func truncPad(s string, w int) string {
378388
if w <= 0 {
379389
return ""
380390
}
391+
w = min(w, maxColWidth)
381392
// expand tabs to 4 spaces for stable columns
382393
s = strings.ReplaceAll(s, "\t", " ")
383394
// runewidth.Truncate fits s into w cells, appending "…" (and reserving its

internal/ui/unicode_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package ui
22

33
import (
4+
"bytes"
5+
"math"
46
"testing"
57

68
"github.com/stretchr/testify/assert"
@@ -34,6 +36,26 @@ func TestTruncPadWideRunes(t *testing.T) {
3436
assert.Equal(t, "世界…", truncPad("世界世", 5))
3537
}
3638

39+
// truncPad must be total: an absurd width (e.g. a user-supplied
40+
// -width=4611686018427387904) must clamp instead of panicking with
41+
// "makeslice: len out of range" inside FillRight. Found by fuzzing
42+
// (ClusterFuzzLite, #99); the pre-#94 hand-rolled loop panicked identically.
43+
func TestTruncPadHugeWidth(t *testing.T) {
44+
res := truncPad("x", math.MaxInt)
45+
assert.Equal(t, maxColWidth, len(res), "pads to the clamped maximum")
46+
assert.Equal(t, "x", res[:1])
47+
}
48+
49+
// The side-by-side divider (strings.Repeat) must survive a huge Width too.
50+
func TestRenderSideBySideHugeWidth(t *testing.T) {
51+
var buf bytes.Buffer
52+
p := &Printer{Out: &buf, Width: math.MaxInt}
53+
54+
assert.NotPanics(t, func() {
55+
p.renderSideBySide("a\nb", "a\nc")
56+
})
57+
}
58+
3759
func TestIndent(t *testing.T) {
3860
// Without trailing newline
3961
assert.Equal(t, " a\n b", indent("a\nb", " "))

0 commit comments

Comments
 (0)