From 7e08d2e390f42578616d51c21eb25796283fa9d0 Mon Sep 17 00:00:00 2001 From: Tiago Peczenyj Date: Wed, 3 Jun 2026 21:33:31 +0200 Subject: [PATCH] fix(ui): clamp column width to avoid makeslice panic on huge -width 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 --- internal/ui/printer.go | 19 +++++++++++++++---- internal/ui/unicode_test.go | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/internal/ui/printer.go b/internal/ui/printer.go index ea1ee96..b07418e 100644 --- a/internal/ui/printer.go +++ b/internal/ui/printer.go @@ -247,20 +247,23 @@ func (p *Printer) renderSideBySide(a, b string) { } sep := " │ " + // Clamp once so the divider's strings.Repeat shares truncPad's bound; an + // unclamped huge Width would panic there with "makeslice: len out of range". + width := min(p.Width, maxColWidth) // Header and divider must share the exact column geometry of the data // rows: each side is `width` columns, joined by sep (" │ "). The divider // mirrors sep as "─┼─" so the ┼ lands directly under every │. // Pad the header text manually (not via %-*s) so it stays correct even // when paint wraps it in ANSI escapes, which %-*s would miscount. fmt.Fprintf(p.Out, " %s%s%s\n", //nolint:errcheck - p.paint(th.Meta, truncPad("current", p.Width)), + p.paint(th.Meta, truncPad("current", width)), sep, p.paint(th.Meta, "proposed")) fmt.Fprintf(p.Out, " %s\n", p.paint(th.Meta, //nolint:errcheck - strings.Repeat("─", p.Width)+"─┼─"+strings.Repeat("─", p.Width))) + strings.Repeat("─", width)+"─┼─"+strings.Repeat("─", width))) for _, r := range rows { - left := truncPad(r.l, p.Width) - right := truncPad(r.r, p.Width) + left := truncPad(r.l, width) + right := truncPad(r.r, width) if r.lc != (Style{}) { left = p.paint(r.lc, left) } @@ -371,13 +374,21 @@ func withTypeName(src, name string) string { return first + "\n" + rest } +// maxColWidth caps the per-side column width. It is far wider than any real +// terminal and keeps padding allocations bounded: an unclamped huge width +// (e.g. -width=1<<62) would make FillRight/strings.Repeat attempt a +// multi-gigabyte allocation and panic with "makeslice: len out of range". +const maxColWidth = 1 << 16 + // truncPad fits s into exactly w display cells: right-padded with spaces when // shorter, or truncated with a trailing "…" when longer. Width is measured in // terminal cells via runewidth, so CJK and other wide runes count as two. +// Widths above maxColWidth are clamped. func truncPad(s string, w int) string { if w <= 0 { return "" } + w = min(w, maxColWidth) // expand tabs to 4 spaces for stable columns s = strings.ReplaceAll(s, "\t", " ") // runewidth.Truncate fits s into w cells, appending "…" (and reserving its diff --git a/internal/ui/unicode_test.go b/internal/ui/unicode_test.go index 6ddd565..9b697b5 100644 --- a/internal/ui/unicode_test.go +++ b/internal/ui/unicode_test.go @@ -1,6 +1,8 @@ package ui import ( + "bytes" + "math" "testing" "github.com/stretchr/testify/assert" @@ -34,6 +36,26 @@ func TestTruncPadWideRunes(t *testing.T) { assert.Equal(t, "世界…", truncPad("世界世", 5)) } +// truncPad must be total: an absurd width (e.g. a user-supplied +// -width=4611686018427387904) must clamp instead of panicking with +// "makeslice: len out of range" inside FillRight. Found by fuzzing +// (ClusterFuzzLite, #99); the pre-#94 hand-rolled loop panicked identically. +func TestTruncPadHugeWidth(t *testing.T) { + res := truncPad("x", math.MaxInt) + assert.Equal(t, maxColWidth, len(res), "pads to the clamped maximum") + assert.Equal(t, "x", res[:1]) +} + +// The side-by-side divider (strings.Repeat) must survive a huge Width too. +func TestRenderSideBySideHugeWidth(t *testing.T) { + var buf bytes.Buffer + p := &Printer{Out: &buf, Width: math.MaxInt} + + assert.NotPanics(t, func() { + p.renderSideBySide("a\nb", "a\nc") + }) +} + func TestIndent(t *testing.T) { // Without trailing newline assert.Equal(t, " a\n b", indent("a\nb", " "))