// Copyright 2025 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package ssh import ( "encoding/binary" "io" "math/bits" "testing" "time" "unsafe" ) func TestMinPayloadSize(t *testing.T) { // 4 GiB (2^32). Declared as a var (not a const) so that int(bigPayload) // is a runtime conversion: a constant conversion would fail to compile // on 32-bit platforms with "constant 4294967296 overflows int". On // 32-bit the value truncates to 0 at runtime, but the is64Bit cases // that reference it are skipped by the runtime check below. var bigPayload int64 = 1 << 32 tests := []struct { name string maxPayload uint32 dataLen int want uint32 is64Bit bool // Flag to run only on 64-bit architectures }{ { name: "Normal Case - Data fits in payload", maxPayload: 32768, dataLen: 1000, want: 1000, }, { name: "Normal Case - Data larger than payload", maxPayload: 32768, dataLen: 50000, want: 32768, }, { name: "Boundary Case - Data zero", maxPayload: 32768, dataLen: 0, want: 0, }, { name: "Overflow Case - Data is exactly 4GB (1<<32)", maxPayload: 32768, dataLen: int(bigPayload), want: 32768, is64Bit: true, }, { name: "Overflow Case - Data is 4GB + small amount", maxPayload: 32768, dataLen: int(bigPayload + 100), want: 32768, is64Bit: true, }, } is64Bit := bits.UintSize == 64 for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.is64Bit && !is64Bit { t.Skip("Skipping test requiring 64-bit int") } got := minPayloadSize(tt.maxPayload, tt.dataLen) if got != tt.want { t.Errorf("minPayloadSize(%d, %d) = %d; want %d", tt.maxPayload, tt.dataLen, got, tt.want) } }) } } // TestWriteExtendedNoInfiniteLoopOnLargeWrite is an end-to-end regression // test for the integer-overflow bug in WriteExtended. Before the fix, a // write whose len(data) was a multiple of 2^32 caused minPayloadSize to // return 0; WriteExtended then spun forever, reserving 0 bytes per // iteration and never advancing the data slice. // // We exercise the real WriteExtended path with a slice whose declared // length is exactly 2^32. Allocating 4 GiB is unnecessary: each iteration // only reads up to maxRemotePayload bytes from the head of the slice, and // the loop blocks in remoteWin.reserve() once the channel window is // exhausted — before the slice base advances past the underlying buffer. // // With the fix, the loop blocks in reserve(); we detect that via // waitWriterBlocked(), then close the window to let WriteExtended return. // With the bug, the loop never blocks and the test times out. // //go:nocheckptr func TestWriteExtendedNoInfiniteLoopOnLargeWrite(t *testing.T) { if bits.UintSize < 64 { t.Skip("test requires 64-bit int to construct a slice with len >= 2^32") } reader, writer, mux := channelPair(t) defer reader.Close() defer writer.Close() defer mux.Close() // Sized to hold the full pre-update remote window so that no iteration // reads past the backing buffer before reserve() blocks. backing := make([]byte, channelWindowSize) var bigLen int64 = 1 << 32 bigSlice := unsafe.Slice(&backing[0], int(bigLen)) done := make(chan int, 1) go func() { n, _ := writer.Write(bigSlice) done <- n }() blocked := make(chan struct{}) go func() { writer.remoteWin.waitWriterBlocked() close(blocked) }() select { case <-blocked: // Good — the loop made progress and is now blocked in reserve(). // Close the window to let WriteExtended return. writer.remoteWin.close() case <-time.After(2 * time.Second): t.Fatal("WriteExtended did not block in reserve within 2s — minPayloadSize likely returned 0 (integer overflow regression)") } select { case n := <-done: if n == 0 { t.Fatalf("WriteExtended returned n=0; expected progress") } case <-time.After(2 * time.Second): t.Fatal("WriteExtended did not return after closing the window") } } func TestDiscardedExtendedDataReturnsWindowCredit(t *testing.T) { client, _, serverChans := forwardingPair(t) clientCh, reqs, err := client.OpenChannel("test", nil) if err != nil { t.Fatalf("OpenChannel: %v", err) } go DiscardRequests(reqs) defer clientCh.Close() serverCh := (<-serverChans).(*channel) defer serverCh.Close() // Write more than the default channel receive window (2 MiB) of // extended data with a type code other than stderr. The peer // discards such data: if it did not return the window credit the // write would block forever once the window is exhausted. const payload = 4 * 1024 * 1024 done := make(chan error, 1) go func() { buf := make([]byte, 32*1024) remaining := payload for remaining > 0 { n := min(len(buf), remaining) nw, err := serverCh.WriteExtended(buf[:n], 2) if err != nil { done <- err return } remaining -= nw } done <- nil }() select { case err := <-done: if err != nil { t.Fatalf("write of discarded extended data: %v", err) } case <-time.After(10 * time.Second): t.Fatal("write of discarded extended data blocked: window credit is not returned") } // The main stream must still be usable. want := []byte("main stream after extended data") if _, err := serverCh.Write(want); err != nil { t.Fatalf("write to main stream: %v", err) } got := make([]byte, len(want)) if _, err := io.ReadFull(clientCh, got); err != nil { t.Fatalf("read from main stream: %v", err) } if string(got) != string(want) { t.Fatalf("read %q from main stream, want %q", got, want) } // Reading the main stream payload above orders the test after all // preceding packets: everything received has now been either read or // discarded, so the full window credit must have been accounted. // Anything less indicates a partial-credit leak that the liveness // check above would not detect. cc := clientCh.(*channel) cc.windowMu.Lock() window := cc.myWindow + cc.myConsumed cc.windowMu.Unlock() if window != channelWindowSize { t.Errorf("myWindow+myConsumed = %d, want %d", window, channelWindowSize) } } func TestDiscardedExtendedDataAfterClose(t *testing.T) { client, _, serverChans := forwardingPair(t) clientCh, reqs, err := client.OpenChannel("test", nil) if err != nil { t.Fatalf("OpenChannel: %v", err) } go DiscardRequests(reqs) serverCh := <-serverChans defer serverCh.Close() ch := clientCh.(*channel) // Close the channel locally: sentClose is now set, but the channel // stays in the mux chanList until the peer's close arrives, so // in-flight data packets are still dispatched to handleData. if err := ch.Close(); err != nil { t.Fatalf("Close: %v", err) } // Deliver extended data packets with an unknown type code, enough to // cross the adjustWindow send threshold of 3*maxIncomingPayload. // The window adjust message cannot be sent after the close and // adjustWindow fails with io.EOF: handleData must swallow that // error, since any error it returns terminates the mux read loop // and tears down every channel on the connection. const packetLen = 32 * 1024 packet := make([]byte, 13+packetLen) packet[0] = msgChannelExtendedData binary.BigEndian.PutUint32(packet[1:], ch.localId) binary.BigEndian.PutUint32(packet[5:], 2) binary.BigEndian.PutUint32(packet[9:], packetLen) for range 5 { if err := ch.handleData(packet); err != nil { t.Fatalf("handleData after local close: %v", err) } } }