blob: 34d9d430593d986098a40e1a508f42f6e236889d [file]
// 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)
}
}
}