Explorar o código

tighten binary result encoding limits and document the shipped contract

README.md still claimed binary results fail with 0A000 before side effects;
replace it with the shipped behavior: Bind result formats are honored per
column for the OIDs in /capabilities, other reported OIDs get 0A000, and the
list is resolved at Execute (after a RETURNING statement has run) because
Describe returns NoData.

Encoding limits:
- float4 rejects values beyond MaxFloat32 instead of silently emitting ±Inf
  (NaN stays NaN).
- numeric ndigits is bounded by MaxInt16, matching the wire field.
- a float64 exactly 2^63 is rejected instead of wrapping to MinInt64.

Capabilities now advertise 1700 only as the numeric[] element type and state
that an unrepresentable stored value fails with 22P02.

Tests: encode failure mid-result (RowDescription + earlier rows + 22P02, no
partial DataRow, transaction failed, connection reusable after ROLLBACK),
int4 overflow (2^40), Close('P') dropping a portal's formats, and float
bit patterns for -0/NaN/±Inf plus the int8 boundary.
Danilo Fragoso hai 1 día
pai
achega
9ac01cd1f7

+ 1 - 1
README.md

@@ -321,7 +321,7 @@ pgcli postgresql://localhost:5432/pizzasql
    - Use `INTEGER PRIMARY KEY AUTOINCREMENT`, not `SERIAL`
    - Use `TEXT`, not `VARCHAR` with enforced length
 2. **Parameter placeholders**: use `$1, $2...` (PostgreSQL style) or `?` (SQLite style). PG Bind rewrites `$n` only outside quoted regions and line/block comments. The HTTP `/query` API substitutes `?` textually, so a statement that uses the jsonb `?` key-existence operator cannot also use `?` positional parameters; use the PostgreSQL protocol or `$n`-free SQL there. Initialization probes such as `SELECT version()` and `SHOW server_version` match complete token sequences, never substrings inside SQL literals or unrelated projections.
-3. **Extended protocol scope**: Parse accepts one command. Describe never executes SQL: statement Describe returns `ParameterDescription` with the negotiated parameter types (a client Parse OID wins, an OID of 0 is inferred from casts, comparisons, assignment targets, and ANY/ALL operands, and otherwise reports TEXT rather than 0) followed by `NoData`, while portal Describe returns `NoData` without a `ParameterDescription`, as in PostgreSQL; `Execute` still sends the real `RowDescription`. A placeholder number above PostgreSQL's 65535-parameter protocol limit fails Parse with `54000`. Bind accepts text parameters for every type and binary parameters for booleans, integers, floats, numeric, jsonb, and the supported array OIDs; unsupported binary parameter OIDs report `0A000`. Binary results and nonzero Execute row limits fail with SQLSTATE `0A000` before side effects. Portals are one-shot, not resumable.
+3. **Extended protocol scope**: Parse accepts one command. Describe never executes SQL: statement Describe returns `ParameterDescription` with the negotiated parameter types (a client Parse OID wins, an OID of 0 is inferred from casts, comparisons, assignment targets, and ANY/ALL operands, and otherwise reports TEXT rather than 0) followed by `NoData`, while portal Describe returns `NoData` without a `ParameterDescription`, as in PostgreSQL; `Execute` still sends the real `RowDescription`. A placeholder number above PostgreSQL's 65535-parameter protocol limit fails Parse with `54000`. Bind accepts text parameters for every type and binary parameters for booleans, integers, floats, numeric, jsonb, and the supported array OIDs; unsupported binary parameter OIDs report `0A000`. Bind result formats are honored per column (`0`=text, `1`=binary) for the OIDs listed by `GET /capabilities`; a binary request for any other reported OID fails with `0A000`. Because Describe returns `NoData`, the format list is resolved when Execute produces the result, so an invalid list or OID is rejected at Execute after an `INSERT`/`UPDATE`/`DELETE ... RETURNING` statement has already run (no rows are sent and no rollback is attempted). Nonzero Execute row limits still fail with `0A000` before side effects. Portals are one-shot, not resumable.
 4. **PostgreSQL compatibility limits**: no schemas or roles. Arrays are one-dimensional and JSONB is a native canonical-text type; see `GET /capabilities` for the exact surface.
 
 ---

+ 1 - 1
pkg/capabilities/capabilities.go

@@ -161,7 +161,7 @@ func Current() *Document {
 			SimpleQuery:    true,
 			ExtendedQuery:  true,
 			Describe:       Feature{Supported: true, Status: "compatibility_no_data", Notes: "statement Describe returns ParameterDescription with the negotiated parameter types (a client Parse OID wins; an OID of 0 is inferred from casts, comparisons, assignment targets, and ANY/ALL operands, and falls back to TEXT rather than 0) then NoData; portal Describe returns NoData without a ParameterDescription, matching PostgreSQL; Execute still emits the real RowDescription; a placeholder number above the 65535-parameter protocol limit fails Parse with 54000 before any allocation"},
-			BinaryResults:  Feature{Supported: true, Status: "supported", Notes: "Bind accepts per-column result formats (0=text, 1=binary): an empty list is all text, a single entry applies to every column, and any other length must equal the result column count (08P01 otherwise); binary is encodable for bool (16), bytea (17), int8 (20), int2 (21), int4 (23), text (25), float4 (700), float8 (701), varchar (1043), numeric (1700), jsonb (3802, version byte 0x01 plus canonical JSON text), and one-dimensional arrays (1000, 1005, 1007, 1009, 1015, 1016, 1021, 1022, 1231) with NULL elements and empty arrays preserved; a binary request for any other reported OID (timestamptz 1184, date 1082) fails with 0A000; formats are resolved when Execute produces the result because Describe returns NoData, so for INSERT/UPDATE/DELETE ... RETURNING the statement runs before a bad format or OID is rejected (no rows are sent and no rollback is attempted); Describe and simple-query results stay text, and numeric array elements are encoded exactly from their stored decimal text"},
+			BinaryResults:  Feature{Supported: true, Status: "supported", Notes: "Bind accepts per-column result formats (0=text, 1=binary): an empty list is all text, a single entry applies to every column, and any other length must equal the result column count (08P01 otherwise); binary is encodable for bool (16), bytea (17), int8 (20), int2 (21), int4 (23), text (25), float4 (700), float8 (701), varchar (1043), numeric (1700) only as the element type of numeric[] (1231), jsonb (3802, version byte 0x01 plus canonical JSON text), and one-dimensional arrays (1000, 1005, 1007, 1009, 1015, 1016, 1021, 1022, 1231) with NULL elements and empty arrays preserved; a binary request for any other reported OID (timestamptz 1184, date 1082) fails with 0A000; formats are resolved when Execute produces the result because Describe returns NoData, so for INSERT/UPDATE/DELETE ... RETURNING the statement runs before a bad format or OID is rejected (no rows are sent and no rollback is attempted); an unrepresentable stored value (for example text in an INTEGER column, or a float4 overflow) fails with 22P02 without emitting the failing DataRow; Describe and simple-query results stay text, and numeric array elements are encoded exactly from their stored decimal text"},
 			BoundedExecute: Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "Execute maxRows must be zero; portals are one-shot, not resumable"},
 			ProxyErrors:    true,
 			TLS:            "terminated by the managed pgproxy, not the engine listener",

+ 2 - 1
pkg/capabilities/capabilities_test.go

@@ -313,8 +313,9 @@ func TestBinaryResultsAreAdvertised(t *testing.T) {
 	}
 	for _, claim := range []string{
 		"bool (16)", "bytea (17)", "int8 (20)", "int2 (21)", "int4 (23)", "text (25)",
-		"float4 (700)", "float8 (701)", "varchar (1043)", "numeric (1700)", "jsonb (3802,",
+		"float4 (700)", "float8 (701)", "varchar (1043)", "numeric (1700) only as the element type of numeric[] (1231)", "jsonb (3802,",
 		"1000, 1005, 1007, 1009, 1015, 1016, 1021, 1022, 1231", "0A000",
+		"unrepresentable stored value", "22P02",
 		"Describe and simple-query results stay text",
 	} {
 		if !strings.Contains(results.Notes, claim) {

+ 8 - 2
pkg/pgserver/result_binary.go

@@ -142,6 +142,12 @@ func appendBinaryResultValue(dst []byte, oid int32, value interface{}) ([]byte,
 		if err != nil {
 			return dst, err
 		}
+		// A value beyond float4's range would otherwise convert to ±Inf and be
+		// decoded as infinity instead of the stored value. NaN compares false
+		// here and stays NaN, which float4 can represent.
+		if math.Abs(f) > math.MaxFloat32 {
+			return dst, binaryEncodeErrorf("value %v does not fit float4", f)
+		}
 		bits := math.Float32bits(float32(f))
 		return append(dst, byte(bits>>24), byte(bits>>16), byte(bits>>8), byte(bits)), nil
 	case 701: // float8
@@ -235,7 +241,7 @@ func binaryInt64(value interface{}, oid int32) (int64, error) {
 	case int8:
 		n = int64(v)
 	case float64:
-		if math.Trunc(v) != v || v < math.MinInt64 || v > math.MaxInt64 {
+		if math.Trunc(v) != v || v < math.MinInt64 || v >= math.MaxInt64 {
 			return 0, binaryEncodeErrorf("value %v is not an integer", v)
 		}
 		n = int64(v)
@@ -495,7 +501,7 @@ func appendNumericText(dst []byte, text string) ([]byte, error) {
 	if weight < math.MinInt16 || weight > math.MaxInt16 {
 		return dst, binaryEncodeErrorf("numeric weight %d is out of range", weight)
 	}
-	if len(groups) > math.MaxUint16 {
+	if len(groups) > math.MaxInt16 {
 		return dst, binaryEncodeErrorf("numeric has %d base-10000 groups, out of range", len(groups))
 	}
 

+ 184 - 0
pkg/pgserver/result_binary_test.go

@@ -890,3 +890,187 @@ func TestBinaryResultsCatalogQuery(t *testing.T) {
 		t.Fatalf("hasindexes = %v, want false", rows[0][1])
 	}
 }
+
+// A stored value the requested binary type cannot represent fails mid-result:
+// the RowDescription and rows already written stay written, the failing DataRow
+// is never emitted, the transaction is marked failed, and the connection stays
+// usable after ROLLBACK.
+func TestBinaryResultsEncodeFailureMidResult(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, x INTEGER)`,
+		`INSERT INTO t VALUES (1, 5), (2, 'abc'), (3, 7)`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+	if msg := findError(runSimple(t, c, conn, "BEGIN")); msg != nil {
+		t.Fatalf("BEGIN failed: %s", errorCode(msg))
+	}
+	conn.reset()
+	sendExtended(t, c,
+		parseMessage("s", `SELECT id, x FROM t ORDER BY id`),
+		bindResultMessage("p", "s", nil, []int16{1, 1}),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if got := messageTypes(messages); got != "12TDEZ" {
+		t.Fatalf("messages = %q, want Parse/Bind/RowDescription/DataRow/Error/ReadyForQuery", got)
+	}
+	rows := dataRowsRaw(t, messages)
+	if len(rows) != 1 {
+		t.Fatalf("got %d DataRows, want only the rows before the failure", len(rows))
+	}
+	assertBytes(t, "row before the failure", rows[0][0], []byte{0, 0, 0, 1})
+	assertBytes(t, "int4 before the failure", rows[0][1], []byte{0, 0, 0, 5})
+	if code := errorCode(findError(messages)); code != ErrCodeInvalidText {
+		t.Fatalf("SQLSTATE = %s, want %s", code, ErrCodeInvalidText)
+	}
+	ready := messages[len(messages)-1]
+	if ready.Type != MsgReadyForQuery || ready.Data[0] != TxStatusFailed {
+		t.Fatalf("transaction status = %c, want failed", ready.Data[0])
+	}
+
+	// The connection recovers after ROLLBACK and a new cycle.
+	conn.reset()
+	if err := c.handleMessage(&Message{Type: MsgQuery, Data: []byte("ROLLBACK\x00")}); err != nil {
+		t.Fatal(err)
+	}
+	conn.reset()
+	sendExtended(t, c,
+		parseMessage("s2", `SELECT x FROM t WHERE id = 1`),
+		bindResultMessage("p2", "s2", nil, []int16{1}),
+		executeMessage("p2"),
+		&Message{Type: MsgSync},
+	)
+	messages = readAllMessages(t, conn.bytes())
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("query after the encode failure failed: %s", errorCode(msg))
+	}
+	if rows := dataRowsRaw(t, messages); len(rows) != 1 || !bytes.Equal(rows[0][0], []byte{0, 0, 0, 5}) {
+		t.Fatalf("rows after recovery = %v", rows)
+	}
+}
+
+// A value that does not fit int4 (2^40 in an INTEGER column) is an encoding
+// failure even when the value converted cleanly at insert time.
+func TestBinaryResultsEncodeFailureOutOfRange(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE big (id INTEGER PRIMARY KEY, x INTEGER)`,
+		`INSERT INTO big VALUES (1, 1099511627776)`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+	sendExtended(t, c,
+		parseMessage("s", `SELECT x FROM big`),
+		bindResultMessage("p", "s", nil, []int16{1}),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if got := messageTypes(messages); got != "12TEZ" {
+		t.Fatalf("messages = %q, want no DataRow for the failing value", got)
+	}
+	if code := errorCode(findError(messages)); code != ErrCodeInvalidText {
+		t.Fatalf("SQLSTATE = %s, want %s", code, ErrCodeInvalidText)
+	}
+}
+
+// Close('P') drops the portal and therefore its result formats; a later Bind
+// under the same name starts from the new list.
+func TestBinaryResultsCloseDropsPortalFormats(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	prepareBinaryResultTable(t, c)
+	sendExtended(t, c,
+		parseMessage("s", `SELECT id FROM t ORDER BY id`),
+		bindResultMessage("p", "s", nil, []int16{1}),
+		executeMessage("p"),
+		&Message{Type: MsgClose, Data: []byte{'P', 'p', 0}},
+		bindResultMessage("p", "s", nil, nil),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("close/re-bind failed: %s", errorCode(msg))
+	}
+	var formats [][]int16
+	var rows [][][]byte
+	for _, message := range messages {
+		switch message.Type {
+		case MsgRowDescription:
+			formats = append(formats, rowDescriptionFormats(t, message.Data))
+		case MsgDataRow:
+			rows = append(rows, dataRowRawValues(t, message))
+		}
+	}
+	if len(formats) != 2 || !equalInt16(formats[0], []int16{1}) {
+		t.Fatalf("formats = %v, want the first portal binary", formats)
+	}
+	if !equalInt16(formats[1], []int16{0}) {
+		t.Fatalf("formats after Close = %v, want text", formats[1])
+	}
+	if len(rows) != 4 {
+		t.Fatalf("got %d rows, want 4", len(rows))
+	}
+	assertBytes(t, "binary portal id", rows[0][0], []byte{0, 0, 0, 1})
+	if string(rows[2][0]) != "1" {
+		t.Fatalf("re-bound text portal id = %q", rows[2][0])
+	}
+}
+
+// Float results keep their exact bit patterns, including -0 and NaN; float4
+// rejects values whose conversion would become an infinity.
+func TestBinaryFloatBitPatterns(t *testing.T) {
+	negativeZero := math.Copysign(0, -1)
+	nan := math.NaN()
+	positiveInfinity := math.Inf(1)
+	negativeInfinity := math.Inf(-1)
+	cases := []struct {
+		name  string
+		oid   int32
+		value interface{}
+		want  []byte
+	}{
+		{"float8 -0", 701, negativeZero, binary.BigEndian.AppendUint64(nil, 0x8000000000000000)},
+		{"float8 NaN", 701, nan, binary.BigEndian.AppendUint64(nil, math.Float64bits(nan))},
+		{"float8 +Inf", 701, positiveInfinity, binary.BigEndian.AppendUint64(nil, 0x7ff0000000000000)},
+		{"float8 -Inf", 701, negativeInfinity, binary.BigEndian.AppendUint64(nil, 0xfff0000000000000)},
+		{"float8 Infinity text", 701, "Infinity", binary.BigEndian.AppendUint64(nil, 0x7ff0000000000000)},
+		{"float4 -0", 700, negativeZero, []byte{0x80, 0, 0, 0}},
+		{"float4 NaN", 700, nan, []byte{0x7f, 0xc0, 0, 0}},
+		{"int8 -2^63", 20, float64(math.MinInt64), []byte{0x80, 0, 0, 0, 0, 0, 0, 0}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := appendBinaryResultValue(nil, tc.oid, tc.value)
+			if err != nil {
+				t.Fatalf("encode %s: %v", tc.name, err)
+			}
+			assertBytes(t, tc.name, got, tc.want)
+		})
+	}
+
+	// float4 cannot represent an infinity, and 2^63 does not fit int8.
+	for _, tc := range []struct {
+		name  string
+		oid   int32
+		value interface{}
+	}{
+		{"float4 +Inf", 700, positiveInfinity},
+		{"float4 -Inf", 700, negativeInfinity},
+		{"float4 Infinity text", 700, "-Infinity"},
+		{"int8 2^63", 20, math.Pow(2, 63)},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			if _, err := appendBinaryResultValue(nil, tc.oid, tc.value); err == nil {
+				t.Fatalf("%s: encoding succeeded, want an error", tc.name)
+			}
+		})
+	}
+}