diff --git a/tool/shelltool/localshell.go b/tool/shelltool/localshell.go index 3fdc9063..82df2e95 100644 --- a/tool/shelltool/localshell.go +++ b/tool/shelltool/localshell.go @@ -398,14 +398,17 @@ func runStateless(ctx context.Context, opts LocalConfig, command string) (Result // -------------------------------------------------------------------------- // headTailBuffer keeps up to cap bytes: the first half as head and the most -// recent half as a rolling tail. When the total exceeds cap, the middle is -// dropped and [Result.Truncated] is set. This mirrors the .NET HeadTailBuffer. +// recent half as a rolling tail. Once a complete rune no longer fits in the +// head, it and all later runes stay in the tail so the final output keeps the +// original UTF-8 ordering. When the total exceeds cap, the middle is dropped +// and [Result.Truncated] is set. This mirrors the .NET HeadTailBuffer. type headTailBuffer struct { cap int head []byte tail [][]byte // queue of complete rune-byte slices tailBytes int totalBytes int + headSealed bool truncated bool } @@ -430,15 +433,16 @@ func (b *headTailBuffer) Write(p []byte) (int, error) { b.totalBytes += size - if len(b.head)+len(encoded) <= headCap { + if !b.headSealed && len(b.head)+len(encoded) <= headCap { b.head = append(b.head, encoded...) continue } - // Head full — append to tail. + // Once a complete rune cannot fit in the head, all later runes stay in the tail. + b.headSealed = true b.tail = append(b.tail, encoded) b.tailBytes += len(encoded) // Evict oldest rune-chunk from tail until within budget. - for b.tailBytes > tailCap && len(b.tail) > 0 { + for b.totalBytes > b.cap && b.tailBytes > tailCap && len(b.tail) > 0 { b.tailBytes -= len(b.tail[0]) b.tail = b.tail[1:] b.truncated = true diff --git a/tool/shelltool/localshell_internal_test.go b/tool/shelltool/localshell_internal_test.go index 9f8d6c12..7eeb999a 100644 --- a/tool/shelltool/localshell_internal_test.go +++ b/tool/shelltool/localshell_internal_test.go @@ -2,7 +2,10 @@ package shelltool -import "testing" +import ( + "strings" + "testing" +) func TestPreserveEnvironmentValuesKeepsOnlyAllowlist(t *testing.T) { source := []string{ @@ -28,3 +31,47 @@ func TestPreserveEnvironmentValuesKeepsOnlyAllowlist(t *testing.T) { t.Fatal("unexpected non-preserved variable copied") } } + +func TestHeadTailBuffer_MultiByteUTF8AtCapPreservesOrder(t *testing.T) { + t.Parallel() + + const input = "aaaaaaa🔥🔥🔥\n" + + buf := &headTailBuffer{cap: 20} + if _, err := buf.Write([]byte(input)); err != nil { + t.Fatalf("write: %v", err) + } + + if buf.truncated { + t.Fatalf("truncated = true, want false") + } + if got := buf.String(); got != input { + t.Fatalf("String() = %q, want %q", got, input) + } +} + +func TestHeadTailBuffer_MultiByteUTF8OverflowPreservesHeadTailOrder(t *testing.T) { + t.Parallel() + + buf := &headTailBuffer{cap: 20} + if _, err := buf.Write([]byte("aaaaaaa🔥🔥🔥x\n")); err != nil { + t.Fatalf("write: %v", err) + } + + got := buf.String() + if !buf.truncated { + t.Fatalf("truncated = false, want true") + } + if !strings.HasPrefix(got, "aaaaaaa\n") { + t.Fatalf("String() = %q, want prefix %q", got, "aaaaaaa\\n") + } + if !strings.Contains(got, "[... truncated 4 bytes ...]") { + t.Fatalf("String() = %q, want truncated marker", got) + } + if !strings.HasSuffix(got, "🔥🔥x\n") { + t.Fatalf("String() = %q, want suffix %q", got, "🔥🔥x\\n") + } + if strings.ContainsRune(got, '\uFFFD') { + t.Fatalf("String() = %q, should not contain replacement rune", got) + } +}