From 6887d0ce05ba1481036891fe3d500a4c826b18b7 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Sun, 6 Sep 2026 00:58:22 +0200 Subject: [PATCH] fix(postgres): range-check int64 narrowing on scan (#502) decodeTextInto, decodeBinaryInto and scanValue converted the decoded int64 into int / int32 / int16 / uint32 with a bare conversion, so an out-of-range value (e.g. 2147483648 scanned into *int32) wrapped silently instead of failing the scan. Add bounds-checking helpers that return a 'celeris-postgres: scan: value N out of range for ' error, mirroring the encoder's int4/int2 overflow checks, and route every narrowing site through them. Destinations are left untouched on error. --- driver/postgres/decode_range_test.go | 176 +++++++++++++++++++++++++++ driver/postgres/pool.go | 89 ++++++++++++-- 2 files changed, 256 insertions(+), 9 deletions(-) create mode 100644 driver/postgres/decode_range_test.go diff --git a/driver/postgres/decode_range_test.go b/driver/postgres/decode_range_test.go new file mode 100644 index 00000000..61596b1a --- /dev/null +++ b/driver/postgres/decode_range_test.go @@ -0,0 +1,176 @@ +package postgres + +import ( + "encoding/binary" + "math" + "strconv" + "strings" + "testing" + + "github.com/goceleris/celeris/driver/postgres/protocol" +) + +// Regression tests for celeris#502: decodeTextInto / decodeBinaryInto +// narrowed the parsed int64 into int / int32 / int16 / uint32 without a +// range check, so an out-of-range value wrapped silently (2147483648 scanned +// into *int32 became -2147483648) instead of failing the scan the way the +// encode side ("int4 overflow") and database/sql do. + +func int8Codec() *protocol.TypeCodec { return &protocol.TypeCodec{OID: protocol.OIDInt8} } + +func beInt8(n int64) []byte { + var b [8]byte + binary.BigEndian.PutUint64(b[:], uint64(n)) + return b[:] +} + +func wantRangeErr(t *testing.T, handled bool, err error, want string) { + t.Helper() + if !handled { + t.Fatalf("destination type not handled by the fast path") + } + if err == nil { + t.Fatalf("expected out-of-range error for %s, got nil", want) + } + if !strings.Contains(err.Error(), "out of range") || !strings.Contains(err.Error(), want) { + t.Fatalf("error %q does not mention out of range for %s", err, want) + } +} + +func TestDecodeTextInto_Int32Overflow(t *testing.T) { + var d int32 = 7 + handled, err := decodeTextInto(&d, []byte("2147483648"), nil) + wantRangeErr(t, handled, err, "int32") + if d != 7 { + t.Fatalf("destination was written on error: %d", d) + } + + d = 7 + handled, err = decodeTextInto(&d, []byte("-2147483649"), nil) + wantRangeErr(t, handled, err, "int32") + if d != 7 { + t.Fatalf("destination was written on error: %d", d) + } +} + +func TestDecodeTextInto_Int32Boundaries(t *testing.T) { + for _, want := range []int64{math.MinInt32, -1, 0, 1, math.MaxInt32} { + var d int32 + handled, err := decodeTextInto(&d, []byte(strconv.FormatInt(want, 10)), nil) + if !handled || err != nil { + t.Fatalf("%d: handled=%v err=%v", want, handled, err) + } + if int64(d) != want { + t.Fatalf("%d: got %d", want, d) + } + } +} + +func TestDecodeTextInto_IntAndInt64Boundaries(t *testing.T) { + for _, want := range []int64{math.MinInt64, math.MinInt, -1, 0, 1, math.MaxInt, math.MaxInt64} { + var d64 int64 + handled, err := decodeTextInto(&d64, []byte(strconv.FormatInt(want, 10)), nil) + if !handled || err != nil || d64 != want { + t.Fatalf("int64 %d: handled=%v err=%v got=%d", want, handled, err, d64) + } + if want < math.MinInt || want > math.MaxInt { + continue // only reachable on 32-bit builds + } + var d int + handled, err = decodeTextInto(&d, []byte(strconv.FormatInt(want, 10)), nil) + if !handled || err != nil { + t.Fatalf("int %d: handled=%v err=%v", want, handled, err) + } + if int64(d) != want { + t.Fatalf("int %d: got %d", want, d) + } + } +} + +func TestDecodeBinaryInto_Int32Overflow(t *testing.T) { + var d int32 = 7 + handled, err := decodeBinaryInto(&d, beInt8(2147483648), int8Codec()) + wantRangeErr(t, handled, err, "int32") + if d != 7 { + t.Fatalf("destination was written on error: %d", d) + } + handled, err = decodeBinaryInto(&d, beInt8(math.MinInt32-1), int8Codec()) + wantRangeErr(t, handled, err, "int32") +} + +func TestDecodeBinaryInto_Int16Overflow(t *testing.T) { + var d int16 + handled, err := decodeBinaryInto(&d, beInt8(math.MaxInt16+1), int8Codec()) + wantRangeErr(t, handled, err, "int16") + handled, err = decodeBinaryInto(&d, beInt8(math.MinInt16-1), int8Codec()) + wantRangeErr(t, handled, err, "int16") +} + +func TestDecodeBinaryInto_Uint32Overflow(t *testing.T) { + var d uint32 + handled, err := decodeBinaryInto(&d, beInt8(-1), int8Codec()) + wantRangeErr(t, handled, err, "uint32") + handled, err = decodeBinaryInto(&d, beInt8(math.MaxUint32+1), int8Codec()) + wantRangeErr(t, handled, err, "uint32") +} + +// scanValue is the driver.Value fallback path (codec-less / non-fast-path +// destinations); it narrowed int64 the same way. +func TestScanValue_IntNarrowingRange(t *testing.T) { + var d32 int32 = 7 + if err := scanValue(&d32, int64(2147483648)); err == nil || !strings.Contains(err.Error(), "out of range for int32") { + t.Fatalf("int32: err=%v", err) + } + if d32 != 7 { + t.Fatalf("int32 destination written on error: %d", d32) + } + var d16 int16 + if err := scanValue(&d16, int64(math.MinInt16-1)); err == nil || !strings.Contains(err.Error(), "out of range for int16") { + t.Fatalf("int16: err=%v", err) + } + if err := scanValue(&d32, int64(math.MaxInt32)); err != nil || d32 != math.MaxInt32 { + t.Fatalf("int32 boundary: err=%v got=%d", err, d32) + } + if err := scanValue(&d16, int64(math.MinInt16)); err != nil || d16 != math.MinInt16 { + t.Fatalf("int16 boundary: err=%v got=%d", err, d16) + } + var di int + if err := scanValue(&di, int64(math.MaxInt)); err != nil || di != math.MaxInt { + t.Fatalf("int boundary: err=%v got=%d", err, di) + } +} + +func TestDecodeBinaryInto_Boundaries(t *testing.T) { + codec := int8Codec() + for _, want := range []int64{math.MinInt32, -1, 0, 1, math.MaxInt32} { + var d int32 + if handled, err := decodeBinaryInto(&d, beInt8(want), codec); !handled || err != nil || int64(d) != want { + t.Fatalf("int32 %d: handled=%v err=%v got=%d", want, handled, err, d) + } + } + for _, want := range []int64{math.MinInt16, -1, 0, 1, math.MaxInt16} { + var d int16 + if handled, err := decodeBinaryInto(&d, beInt8(want), codec); !handled || err != nil || int64(d) != want { + t.Fatalf("int16 %d: handled=%v err=%v got=%d", want, handled, err, d) + } + } + for _, want := range []int64{0, 1, math.MaxUint32} { + var d uint32 + if handled, err := decodeBinaryInto(&d, beInt8(want), codec); !handled || err != nil || int64(d) != want { + t.Fatalf("uint32 %d: handled=%v err=%v got=%d", want, handled, err, d) + } + } + for _, want := range []int64{math.MinInt64, -1, 0, 1, math.MaxInt64} { + var d int64 + if handled, err := decodeBinaryInto(&d, beInt8(want), codec); !handled || err != nil || d != want { + t.Fatalf("int64 %d: handled=%v err=%v got=%d", want, handled, err, d) + } + if want < math.MinInt || want > math.MaxInt { + continue + } + var di int + if handled, err := decodeBinaryInto(&di, beInt8(want), codec); !handled || err != nil || int64(di) != want { + t.Fatalf("int %d: handled=%v err=%v got=%d", want, handled, err, di) + } + } +} diff --git a/driver/postgres/pool.go b/driver/postgres/pool.go index 52379b84..16571528 100644 --- a/driver/postgres/pool.go +++ b/driver/postgres/pool.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math" "sync" "sync/atomic" "time" @@ -704,6 +705,40 @@ func decodeToValue(raw []byte, codec *protocol.TypeCodec, col protocol.ColumnDes return cp, nil } +// The int64→narrower helpers below are the scan-side counterpart of the +// encoder's "int4 overflow" / "int2 overflow" checks: a value that does not +// fit the destination fails the scan instead of wrapping silently +// (celeris#502). Each returns the zero value with the error so callers can +// leave the destination untouched. + +func int64ToInt(n int64) (int, error) { + if n < math.MinInt || n > math.MaxInt { + return 0, fmt.Errorf("celeris-postgres: scan: value %d out of range for int", n) + } + return int(n), nil +} + +func int64ToInt32(n int64) (int32, error) { + if n < math.MinInt32 || n > math.MaxInt32 { + return 0, fmt.Errorf("celeris-postgres: scan: value %d out of range for int32", n) + } + return int32(n), nil +} + +func int64ToInt16(n int64) (int16, error) { + if n < math.MinInt16 || n > math.MaxInt16 { + return 0, fmt.Errorf("celeris-postgres: scan: value %d out of range for int16", n) + } + return int16(n), nil +} + +func int64ToUint32(n int64) (uint32, error) { + if n < 0 || n > math.MaxUint32 { + return 0, fmt.Errorf("celeris-postgres: scan: value %d out of range for uint32", n) + } + return uint32(n), nil +} + // decodeBinaryInto decodes raw binary-format bytes directly into dest // for the common primitive types. Returns (true, err) when the type was // handled (err may be non-nil for decode failures); (false, nil) when @@ -715,7 +750,11 @@ func decodeBinaryInto(dest any, raw []byte, codec *protocol.TypeCodec) (bool, er if err != nil { return true, err } - *d = int(n) + v, err := int64ToInt(n) + if err != nil { + return true, err + } + *d = v return true, nil case *int64: n, err := protocol.DecodeIntBinary(raw, codec) @@ -729,21 +768,33 @@ func decodeBinaryInto(dest any, raw []byte, codec *protocol.TypeCodec) (bool, er if err != nil { return true, err } - *d = int32(n) + v, err := int64ToInt32(n) + if err != nil { + return true, err + } + *d = v return true, nil case *int16: n, err := protocol.DecodeIntBinary(raw, codec) if err != nil { return true, err } - *d = int16(n) + v, err := int64ToInt16(n) + if err != nil { + return true, err + } + *d = v return true, nil case *uint32: n, err := protocol.DecodeIntBinary(raw, codec) if err != nil { return true, err } - *d = uint32(n) + v, err := int64ToUint32(n) + if err != nil { + return true, err + } + *d = v return true, nil case *string: // Fast path for text/varchar/etc.: no interface boxing, single @@ -774,7 +825,11 @@ func decodeTextInto(dest any, raw []byte, _ *protocol.TypeCodec) (bool, error) { if err != nil { return true, err } - *d = int(n) + v, err := int64ToInt(n) + if err != nil { + return true, err + } + *d = v return true, nil case *int64: n, err := protocol.ParseIntTextASCII(raw) @@ -788,7 +843,11 @@ func decodeTextInto(dest any, raw []byte, _ *protocol.TypeCodec) (bool, error) { if err != nil { return true, err } - *d = int32(n) + v, err := int64ToInt32(n) + if err != nil { + return true, err + } + *d = v return true, nil case *string: *d = string(raw) @@ -868,7 +927,11 @@ func convertAssign(dest any, src any) error { case *int: switch s := src.(type) { case int64: - *d = int(s) + v, err := int64ToInt(s) + if err != nil { + return err + } + *d = v case float64: *d = int(s) case string: @@ -888,14 +951,22 @@ func convertAssign(dest any, src any) error { case *int32: switch s := src.(type) { case int64: - *d = int32(s) + v, err := int64ToInt32(s) + if err != nil { + return err + } + *d = v default: return fmt.Errorf("celeris-postgres: scan: cannot convert %T to int32", src) } case *int16: switch s := src.(type) { case int64: - *d = int16(s) + v, err := int64ToInt16(s) + if err != nil { + return err + } + *d = v default: return fmt.Errorf("celeris-postgres: scan: cannot convert %T to int16", src) }