Skip to main content

reddb_server/wire/postgres/
protocol.rs

1//! PostgreSQL v3 wire protocol message framing (Phase 3.1 PG parity).
2//!
3//! Implements the bits of the PG v3 protocol RedDB needs for simple
4//! query support: startup negotiation, authentication (trust), the
5//! simple query flow (`Q` → `T`/`D`*/`C`/`Z`), and error reporting.
6//!
7//! The full PG reference lives at:
8//! <https://www.postgresql.org/docs/current/protocol-message-formats.html>
9//!
10//! # Frame format (v3)
11//!
12//! After the startup message, every frame is:
13//! ```text
14//! [u8 type] [i32 length (includes itself)] [payload]
15//! ```
16//! Frames are big-endian. We use `tokio::io::AsyncRead/Write` so the
17//! listener can plug into the same task model as the existing wire
18//! binary protocol.
19
20use std::io;
21
22use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
23
24/// Protocol version constant: 3.0 → 196608 (major<<16 | minor).
25pub const PG_PROTOCOL_V3: u32 = 3 << 16;
26
27/// Special startup-phase requests that share the StartupMessage length
28/// header. The PG reference calls out three: SSLRequest (80877103),
29/// GSSENCRequest (80877104), CancelRequest (80877102).
30pub const PG_SSL_REQUEST: u32 = 80877103;
31pub const PG_GSSENC_REQUEST: u32 = 80877104;
32pub const PG_CANCEL_REQUEST: u32 = 80877102;
33
34/// Error type surfaced by the framing layer. Wraps IO errors plus
35/// structural validation failures (bad message tag, truncated frame).
36#[derive(Debug)]
37pub enum PgWireError {
38    Io(io::Error),
39    Protocol(String),
40    /// Client closed the connection cleanly (EOF before a frame).
41    Eof,
42}
43
44impl From<io::Error> for PgWireError {
45    fn from(err: io::Error) -> Self {
46        if err.kind() == io::ErrorKind::UnexpectedEof {
47            PgWireError::Eof
48        } else {
49            PgWireError::Io(err)
50        }
51    }
52}
53
54impl std::fmt::Display for PgWireError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            PgWireError::Io(e) => write!(f, "pg wire io: {e}"),
58            PgWireError::Protocol(m) => write!(f, "pg wire protocol: {m}"),
59            PgWireError::Eof => write!(f, "pg wire eof"),
60        }
61    }
62}
63
64impl std::error::Error for PgWireError {}
65
66/// Frontend (client → server) messages we parse.
67#[derive(Debug, Clone)]
68pub enum FrontendMessage {
69    /// Pre-handshake StartupMessage payload (parameters map).
70    Startup(StartupParams),
71    /// SSL handshake request — we reject with 'N' (not supported).
72    SslRequest,
73    /// GSSAPI encryption request — we reject with 'N'.
74    GssEncRequest,
75    /// `Q` — simple query.
76    Query(String),
77    /// `P` — Parse: name a prepared statement and its SQL text.
78    Parse(ParseMessage),
79    /// `B` — Bind: attach concrete parameter bytes to a named statement.
80    Bind(BindMessage),
81    /// `D` — Describe a prepared statement or portal.
82    Describe(DescribeMessage),
83    /// `E` — Execute a bound portal.
84    Execute(ExecuteMessage),
85    /// `C` — Close a prepared statement or portal.
86    Close(CloseMessage),
87    /// `p` — password / SASL response. Payload is the cleartext password
88    /// (NUL-terminated) when answering AuthenticationCleartextPassword.
89    PasswordMessage(Vec<u8>),
90    /// `X` — Terminate.
91    Terminate,
92    /// `H` — Flush. Send buffered results.
93    Flush,
94    /// `S` — Sync. End of extended query batch.
95    Sync,
96    /// Any other frame we don't implement yet; carries the raw tag for
97    /// logging / ErrorResponse reply.
98    Unknown { tag: u8, payload: Vec<u8> },
99}
100
101#[derive(Debug, Clone)]
102pub struct ParseMessage {
103    pub statement: String,
104    pub query: String,
105    pub param_type_oids: Vec<u32>,
106}
107
108#[derive(Debug, Clone)]
109pub struct BindMessage {
110    pub portal: String,
111    pub statement: String,
112    pub param_format_codes: Vec<i16>,
113    pub params: Vec<Option<Vec<u8>>>,
114    pub result_format_codes: Vec<i16>,
115}
116
117#[derive(Debug, Clone)]
118pub struct DescribeMessage {
119    pub target: DescribeTarget,
120    pub name: String,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum DescribeTarget {
125    Statement,
126    Portal,
127}
128
129#[derive(Debug, Clone)]
130pub struct ExecuteMessage {
131    pub portal: String,
132    pub max_rows: u32,
133}
134
135#[derive(Debug, Clone)]
136pub struct CloseMessage {
137    pub target: DescribeTarget,
138    pub name: String,
139}
140
141#[derive(Debug, Clone, Default)]
142pub struct StartupParams {
143    /// Key/value pairs from the startup message (user, database, etc.).
144    pub params: Vec<(String, String)>,
145}
146
147impl StartupParams {
148    pub fn get(&self, key: &str) -> Option<&str> {
149        self.params
150            .iter()
151            .find(|(k, _)| k == key)
152            .map(|(_, v)| v.as_str())
153    }
154}
155
156/// Backend (server → client) messages we emit.
157#[derive(Debug, Clone)]
158pub enum BackendMessage {
159    /// `R` — AuthenticationOk (subtype 0).
160    AuthenticationOk,
161    /// `R` — AuthenticationCleartextPassword (subtype 3). Prompts the
162    /// client to reply with a `PasswordMessage` carrying the cleartext
163    /// password to verify against the auth store.
164    AuthenticationCleartextPassword,
165    /// `S` — ParameterStatus (server_version, client_encoding, ...).
166    ParameterStatus { name: String, value: String },
167    /// `K` — BackendKeyData (cancel key).
168    BackendKeyData { pid: u32, key: u32 },
169    /// `Z` — ReadyForQuery. Status: 'I' idle, 'T' in-txn, 'E' failed-txn.
170    ReadyForQuery(TransactionStatus),
171    /// `T` — RowDescription.
172    RowDescription(Vec<ColumnDescriptor>),
173    /// `D` — DataRow. Each field is `Some(bytes)` or `None` (NULL).
174    DataRow(Vec<Option<Vec<u8>>>),
175    /// `C` — CommandComplete (e.g. "SELECT 3", "INSERT 0 1").
176    CommandComplete(String),
177    /// `1` — ParseComplete.
178    ParseComplete,
179    /// `2` — BindComplete.
180    BindComplete,
181    /// `3` — CloseComplete.
182    CloseComplete,
183    /// `t` — ParameterDescription.
184    ParameterDescription(Vec<u32>),
185    /// `s` — PortalSuspended.
186    PortalSuspended,
187    /// `n` — NoData.
188    NoData,
189    /// `E` — ErrorResponse with severity + code + message.
190    ErrorResponse {
191        severity: String,
192        code: String,
193        message: String,
194    },
195    /// `N` — NoticeResponse (non-fatal).
196    NoticeResponse { message: String },
197    /// `I` — EmptyQueryResponse.
198    EmptyQueryResponse,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum TransactionStatus {
203    /// Not inside a transaction.
204    Idle,
205    /// Inside a transaction block.
206    InTransaction,
207    /// Failed transaction, awaiting ROLLBACK.
208    Failed,
209}
210
211impl TransactionStatus {
212    pub fn as_byte(self) -> u8 {
213        match self {
214            TransactionStatus::Idle => b'I',
215            TransactionStatus::InTransaction => b'T',
216            TransactionStatus::Failed => b'E',
217        }
218    }
219}
220
221#[derive(Debug, Clone)]
222pub struct ColumnDescriptor {
223    pub name: String,
224    /// Table OID (0 when not from a real table — common for computed columns).
225    pub table_oid: u32,
226    /// Column attribute number within the table (0 when synthetic).
227    pub column_attr: i16,
228    /// PG type OID (`pg_type.oid`).
229    pub type_oid: u32,
230    /// Fixed size of the data type, or -1 for variable length.
231    pub type_size: i16,
232    /// Type modifier (e.g. VARCHAR(n) → n+4). -1 when unused.
233    pub type_mod: i32,
234    /// Format code: 0 = text, 1 = binary. We always emit text in 3.1.
235    pub format: i16,
236}
237
238// ────────────────────────────────────────────────────────────────────
239// Frontend parsing
240// ────────────────────────────────────────────────────────────────────
241
242/// Read the initial StartupMessage (or SSL/GSS request). The startup
243/// frame has no type byte — just a length prefix followed by the
244/// payload. Returns either a decoded Startup/SSL/GSS message or an error.
245pub async fn read_startup<R: AsyncRead + Unpin>(
246    stream: &mut R,
247) -> Result<FrontendMessage, PgWireError> {
248    let mut len_buf = [0u8; 4];
249    stream.read_exact(&mut len_buf).await?;
250    let len = u32::from_be_bytes(len_buf);
251    if !(8..=65536).contains(&len) {
252        return Err(PgWireError::Protocol(format!(
253            "startup length {len} out of range"
254        )));
255    }
256    let body_len = (len as usize) - 4;
257    let mut body = vec![0u8; body_len];
258    stream.read_exact(&mut body).await?;
259    if body_len < 4 {
260        return Err(PgWireError::Protocol("startup payload too short".into()));
261    }
262    let version = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
263
264    match version {
265        PG_SSL_REQUEST => Ok(FrontendMessage::SslRequest),
266        PG_GSSENC_REQUEST => Ok(FrontendMessage::GssEncRequest),
267        PG_PROTOCOL_V3 => {
268            // Parameter map is a run of null-terminated strings terminated
269            // by an empty string.
270            let mut params: Vec<(String, String)> = Vec::new();
271            let mut pos = 4usize;
272            while pos < body_len {
273                if body[pos] == 0 {
274                    break;
275                }
276                let key = read_cstring(&body, &mut pos)?;
277                if pos >= body_len {
278                    return Err(PgWireError::Protocol(
279                        "startup parameter missing value".into(),
280                    ));
281                }
282                let value = read_cstring(&body, &mut pos)?;
283                params.push((key, value));
284            }
285            Ok(FrontendMessage::Startup(StartupParams { params }))
286        }
287        // CancelRequest is sent on a fresh connection and doesn't produce
288        // a response — surface as Unknown so caller can close.
289        PG_CANCEL_REQUEST => Ok(FrontendMessage::Unknown {
290            tag: b'K',
291            payload: body,
292        }),
293        _ => Err(PgWireError::Protocol(format!(
294            "unsupported protocol version {version}"
295        ))),
296    }
297}
298
299/// Read a regular tagged frame after the startup handshake.
300pub async fn read_frame<R: AsyncRead + Unpin>(
301    stream: &mut R,
302) -> Result<FrontendMessage, PgWireError> {
303    let mut tag_buf = [0u8; 1];
304    match stream.read_exact(&mut tag_buf).await {
305        Ok(_) => {}
306        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Err(PgWireError::Eof),
307        Err(e) => return Err(PgWireError::Io(e)),
308    }
309    let tag = tag_buf[0];
310
311    let mut len_buf = [0u8; 4];
312    stream.read_exact(&mut len_buf).await?;
313    let len = u32::from_be_bytes(len_buf);
314    if !(4..=1_048_576).contains(&len) {
315        return Err(PgWireError::Protocol(format!(
316            "frame length {len} out of bounds"
317        )));
318    }
319    let payload_len = (len as usize) - 4;
320    let mut payload = vec![0u8; payload_len];
321    stream.read_exact(&mut payload).await?;
322
323    Ok(match tag {
324        b'Q' => {
325            // Null-terminated SQL string.
326            let mut pos = 0;
327            let query = read_cstring(&payload, &mut pos)?;
328            FrontendMessage::Query(query)
329        }
330        b'P' => FrontendMessage::Parse(parse_parse_message(&payload)?),
331        b'B' => FrontendMessage::Bind(parse_bind_message(&payload)?),
332        b'D' => FrontendMessage::Describe(parse_describe_message(&payload)?),
333        b'E' => FrontendMessage::Execute(parse_execute_message(&payload)?),
334        b'C' => FrontendMessage::Close(parse_close_message(&payload)?),
335        b'p' => FrontendMessage::PasswordMessage(payload),
336        b'X' => FrontendMessage::Terminate,
337        b'H' => FrontendMessage::Flush,
338        b'S' => FrontendMessage::Sync,
339        other => FrontendMessage::Unknown {
340            tag: other,
341            payload,
342        },
343    })
344}
345
346// ────────────────────────────────────────────────────────────────────
347// Backend emission
348// ────────────────────────────────────────────────────────────────────
349
350/// Emit a raw byte (used for the SSL/GSS negotiation response: 'N'
351/// meaning "not supported, continue in plaintext").
352pub async fn write_raw_byte<W: AsyncWrite + Unpin>(
353    stream: &mut W,
354    byte: u8,
355) -> Result<(), PgWireError> {
356    stream.write_all(&[byte]).await?;
357    Ok(())
358}
359
360/// Serialize + send a backend message.
361pub async fn write_frame<W: AsyncWrite + Unpin>(
362    stream: &mut W,
363    msg: &BackendMessage,
364) -> Result<(), PgWireError> {
365    let (tag, payload) = encode_backend(msg);
366    // Length includes the length field itself (4 bytes) + payload.
367    let length = (payload.len() + 4) as u32;
368    stream.write_all(&[tag]).await?;
369    stream.write_all(&length.to_be_bytes()).await?;
370    stream.write_all(&payload).await?;
371    Ok(())
372}
373
374/// F-02 (audit doc, 2026-05-06):
375/// PG3 wire encodes user-controlled bytes as `tag|value|NUL` C-strings
376/// in `ErrorResponse`, `NoticeResponse`, `CommandComplete`,
377/// `RowDescription` column names, and `ParameterStatus`. An embedded
378/// NUL in a user-supplied message field truncates the C-string and
379/// lets an attacker smuggle additional protocol fields into the frame.
380///
381/// Mitigation: every byte slice that gets followed by a `\0` terminator
382/// passes through `sanitize_cstring_bytes` first, which substitutes the
383/// Unicode replacement codepoint `U+FFFD` (3 UTF-8 bytes: `EF BF BD`)
384/// for any embedded NUL byte. The substitution preserves the visible
385/// shape of the message for debugging without giving an attacker a
386/// path to inject a synthetic protocol field. Emitting `U+FFFD` is
387/// safe for the PG client side: every PG client we know of reports
388/// errors as opaque strings rather than parsing them.
389fn sanitize_cstring_bytes(input: &[u8]) -> Vec<u8> {
390    if !input.contains(&0) {
391        return input.to_vec();
392    }
393    let mut out = Vec::with_capacity(input.len() + 8);
394    for &b in input {
395        if b == 0 {
396            // U+FFFD REPLACEMENT CHARACTER (UTF-8 EF BF BD)
397            out.extend_from_slice(&[0xEF, 0xBF, 0xBD]);
398        } else {
399            out.push(b);
400        }
401    }
402    out
403}
404
405#[inline]
406fn push_cstring(buf: &mut Vec<u8>, value: &str) {
407    buf.extend_from_slice(&sanitize_cstring_bytes(value.as_bytes()));
408    buf.push(0);
409}
410
411fn encode_backend(msg: &BackendMessage) -> (u8, Vec<u8>) {
412    match msg {
413        BackendMessage::AuthenticationOk => {
414            // Subtype 0 = AuthenticationOk.
415            (b'R', vec![0, 0, 0, 0])
416        }
417        BackendMessage::AuthenticationCleartextPassword => {
418            // Subtype 3 = AuthenticationCleartextPassword.
419            (b'R', vec![0, 0, 0, 3])
420        }
421        BackendMessage::ParameterStatus { name, value } => {
422            let mut buf = Vec::with_capacity(name.len() + value.len() + 2);
423            // F-02: name + value are user-controlled in some pathways.
424            push_cstring(&mut buf, name);
425            push_cstring(&mut buf, value);
426            (b'S', buf)
427        }
428        BackendMessage::BackendKeyData { pid, key } => {
429            let mut buf = Vec::with_capacity(8);
430            buf.extend_from_slice(&pid.to_be_bytes());
431            buf.extend_from_slice(&key.to_be_bytes());
432            (b'K', buf)
433        }
434        BackendMessage::ReadyForQuery(status) => (b'Z', vec![status.as_byte()]),
435        BackendMessage::RowDescription(cols) => {
436            let mut buf = Vec::new();
437            buf.extend_from_slice(&(cols.len() as i16).to_be_bytes());
438            for col in cols {
439                // F-02: column name is user-derived (SELECT ... AS "x\0y").
440                push_cstring(&mut buf, &col.name);
441                buf.extend_from_slice(&col.table_oid.to_be_bytes());
442                buf.extend_from_slice(&col.column_attr.to_be_bytes());
443                buf.extend_from_slice(&col.type_oid.to_be_bytes());
444                buf.extend_from_slice(&col.type_size.to_be_bytes());
445                buf.extend_from_slice(&col.type_mod.to_be_bytes());
446                buf.extend_from_slice(&col.format.to_be_bytes());
447            }
448            (b'T', buf)
449        }
450        BackendMessage::DataRow(fields) => {
451            let mut buf = Vec::new();
452            buf.extend_from_slice(&(fields.len() as i16).to_be_bytes());
453            for field in fields {
454                match field {
455                    None => {
456                        // -1 length signals NULL.
457                        buf.extend_from_slice(&(-1i32).to_be_bytes());
458                    }
459                    Some(bytes) => {
460                        // DataRow uses length-prefixed bytes, NOT
461                        // C-strings — embedded NULs are legal here
462                        // and must NOT be sanitized.
463                        buf.extend_from_slice(&(bytes.len() as i32).to_be_bytes());
464                        buf.extend_from_slice(bytes);
465                    }
466                }
467            }
468            (b'D', buf)
469        }
470        BackendMessage::CommandComplete(tag) => {
471            let mut buf = Vec::with_capacity(tag.len() + 1);
472            // F-02: command tag includes user-influenced row counts /
473            // statement classes; sanitize before NUL-terminating.
474            push_cstring(&mut buf, tag);
475            (b'C', buf)
476        }
477        BackendMessage::ParseComplete => (b'1', Vec::new()),
478        BackendMessage::BindComplete => (b'2', Vec::new()),
479        BackendMessage::CloseComplete => (b'3', Vec::new()),
480        BackendMessage::ParameterDescription(oids) => {
481            let mut buf = Vec::with_capacity(2 + oids.len() * 4);
482            buf.extend_from_slice(&(oids.len() as i16).to_be_bytes());
483            for oid in oids {
484                buf.extend_from_slice(&oid.to_be_bytes());
485            }
486            (b't', buf)
487        }
488        BackendMessage::PortalSuspended => (b's', Vec::new()),
489        BackendMessage::NoData => (b'n', Vec::new()),
490        BackendMessage::ErrorResponse {
491            severity,
492            code,
493            message,
494        } => {
495            let mut buf = Vec::new();
496            // Field 'S' = severity (ERROR, FATAL, PANIC, ...)
497            buf.push(b'S');
498            push_cstring(&mut buf, severity);
499            // Field 'V' = non-localized severity (PG 9.6+).
500            buf.push(b'V');
501            push_cstring(&mut buf, severity);
502            // Field 'C' = SQLSTATE.
503            buf.push(b'C');
504            push_cstring(&mut buf, code);
505            // Field 'M' = human message — F-02 primary attack surface.
506            buf.push(b'M');
507            push_cstring(&mut buf, message);
508            // Trailing null terminator ends the field list.
509            buf.push(0);
510            (b'E', buf)
511        }
512        BackendMessage::NoticeResponse { message } => {
513            let mut buf = Vec::new();
514            buf.push(b'S');
515            buf.extend_from_slice(b"NOTICE");
516            buf.push(0);
517            buf.push(b'M');
518            // F-02: message is user-influenced.
519            push_cstring(&mut buf, message);
520            buf.push(0);
521            (b'N', buf)
522        }
523        BackendMessage::EmptyQueryResponse => (b'I', Vec::new()),
524    }
525}
526
527// ────────────────────────────────────────────────────────────────────
528// Helpers
529// ────────────────────────────────────────────────────────────────────
530
531/// Read a C-string (null-terminated UTF-8) starting at `pos`. Advances
532/// `pos` past the terminator. Returns `Protocol` error when malformed.
533fn read_cstring(buf: &[u8], pos: &mut usize) -> Result<String, PgWireError> {
534    let start = *pos;
535    while *pos < buf.len() && buf[*pos] != 0 {
536        *pos += 1;
537    }
538    if *pos >= buf.len() {
539        return Err(PgWireError::Protocol("cstring missing terminator".into()));
540    }
541    let s = std::str::from_utf8(&buf[start..*pos])
542        .map_err(|e| PgWireError::Protocol(format!("invalid utf8: {e}")))?
543        .to_string();
544    *pos += 1; // skip null
545    Ok(s)
546}
547
548fn parse_parse_message(payload: &[u8]) -> Result<ParseMessage, PgWireError> {
549    let mut pos = 0;
550    let statement = read_cstring(payload, &mut pos)?;
551    let query = read_cstring(payload, &mut pos)?;
552    let nparams = read_i16(payload, &mut pos, "Parse parameter count")?;
553    if nparams < 0 {
554        return Err(PgWireError::Protocol(
555            "negative Parse parameter count".into(),
556        ));
557    }
558    let mut param_type_oids = Vec::with_capacity(nparams as usize);
559    for _ in 0..nparams {
560        param_type_oids.push(read_u32(payload, &mut pos, "Parse parameter type OID")?);
561    }
562    ensure_consumed(payload, pos, "Parse")?;
563    Ok(ParseMessage {
564        statement,
565        query,
566        param_type_oids,
567    })
568}
569
570fn parse_bind_message(payload: &[u8]) -> Result<BindMessage, PgWireError> {
571    let mut pos = 0;
572    let portal = read_cstring(payload, &mut pos)?;
573    let statement = read_cstring(payload, &mut pos)?;
574
575    let nformats = read_i16(payload, &mut pos, "Bind format count")?;
576    if nformats < 0 {
577        return Err(PgWireError::Protocol("negative Bind format count".into()));
578    }
579    let mut param_format_codes = Vec::with_capacity(nformats as usize);
580    for _ in 0..nformats {
581        param_format_codes.push(read_i16(payload, &mut pos, "Bind format code")?);
582    }
583
584    let nparams = read_i16(payload, &mut pos, "Bind parameter count")?;
585    if nparams < 0 {
586        return Err(PgWireError::Protocol(
587            "negative Bind parameter count".into(),
588        ));
589    }
590    let mut params = Vec::with_capacity(nparams as usize);
591    for _ in 0..nparams {
592        let len = read_i32(payload, &mut pos, "Bind parameter length")?;
593        if len == -1 {
594            params.push(None);
595        } else if len < -1 {
596            return Err(PgWireError::Protocol(
597                "invalid Bind parameter length".into(),
598            ));
599        } else {
600            params.push(Some(
601                read_bytes(payload, &mut pos, len as usize, "Bind parameter")?.to_vec(),
602            ));
603        }
604    }
605
606    let nresult_formats = read_i16(payload, &mut pos, "Bind result format count")?;
607    if nresult_formats < 0 {
608        return Err(PgWireError::Protocol(
609            "negative Bind result format count".into(),
610        ));
611    }
612    let mut result_format_codes = Vec::with_capacity(nresult_formats as usize);
613    for _ in 0..nresult_formats {
614        result_format_codes.push(read_i16(payload, &mut pos, "Bind result format code")?);
615    }
616    ensure_consumed(payload, pos, "Bind")?;
617
618    Ok(BindMessage {
619        portal,
620        statement,
621        param_format_codes,
622        params,
623        result_format_codes,
624    })
625}
626
627fn parse_describe_message(payload: &[u8]) -> Result<DescribeMessage, PgWireError> {
628    let mut pos = 0;
629    let target = read_describe_target(payload, &mut pos, "Describe")?;
630    let name = read_cstring(payload, &mut pos)?;
631    ensure_consumed(payload, pos, "Describe")?;
632    Ok(DescribeMessage { target, name })
633}
634
635fn parse_execute_message(payload: &[u8]) -> Result<ExecuteMessage, PgWireError> {
636    let mut pos = 0;
637    let portal = read_cstring(payload, &mut pos)?;
638    let max_rows = read_u32(payload, &mut pos, "Execute max rows")?;
639    ensure_consumed(payload, pos, "Execute")?;
640    Ok(ExecuteMessage { portal, max_rows })
641}
642
643fn parse_close_message(payload: &[u8]) -> Result<CloseMessage, PgWireError> {
644    let mut pos = 0;
645    let target = read_describe_target(payload, &mut pos, "Close")?;
646    let name = read_cstring(payload, &mut pos)?;
647    ensure_consumed(payload, pos, "Close")?;
648    Ok(CloseMessage { target, name })
649}
650
651fn read_describe_target(
652    payload: &[u8],
653    pos: &mut usize,
654    frame: &'static str,
655) -> Result<DescribeTarget, PgWireError> {
656    let byte = *read_bytes(payload, pos, 1, frame)?
657        .first()
658        .expect("one target byte");
659    match byte {
660        b'S' => Ok(DescribeTarget::Statement),
661        b'P' => Ok(DescribeTarget::Portal),
662        other => Err(PgWireError::Protocol(format!(
663            "{frame} target must be 'S' or 'P', got 0x{other:02x}"
664        ))),
665    }
666}
667
668fn read_i16(payload: &[u8], pos: &mut usize, field: &'static str) -> Result<i16, PgWireError> {
669    let bytes = read_bytes(payload, pos, 2, field)?;
670    Ok(i16::from_be_bytes([bytes[0], bytes[1]]))
671}
672
673fn read_i32(payload: &[u8], pos: &mut usize, field: &'static str) -> Result<i32, PgWireError> {
674    let bytes = read_bytes(payload, pos, 4, field)?;
675    Ok(i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
676}
677
678fn read_u32(payload: &[u8], pos: &mut usize, field: &'static str) -> Result<u32, PgWireError> {
679    let bytes = read_bytes(payload, pos, 4, field)?;
680    Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
681}
682
683fn read_bytes<'a>(
684    payload: &'a [u8],
685    pos: &mut usize,
686    len: usize,
687    field: &'static str,
688) -> Result<&'a [u8], PgWireError> {
689    let end = pos
690        .checked_add(len)
691        .ok_or_else(|| PgWireError::Protocol(format!("{field} length overflow")))?;
692    if end > payload.len() {
693        return Err(PgWireError::Protocol(format!("{field} truncated")));
694    }
695    let bytes = &payload[*pos..end];
696    *pos = end;
697    Ok(bytes)
698}
699
700fn ensure_consumed(payload: &[u8], pos: usize, frame: &'static str) -> Result<(), PgWireError> {
701    if pos == payload.len() {
702        Ok(())
703    } else {
704        Err(PgWireError::Protocol(format!(
705            "{frame} had {} trailing bytes",
706            payload.len() - pos
707        )))
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[tokio::test]
716    async fn parse_startup_v3() {
717        // length (4) + version (4) + user\0val\0 + terminator\0
718        let mut payload: Vec<u8> = Vec::new();
719        payload.extend_from_slice(&PG_PROTOCOL_V3.to_be_bytes());
720        payload.extend_from_slice(b"user\0alice\0");
721        payload.push(0);
722        let len = (4 + payload.len()) as u32;
723        let mut frame = Vec::new();
724        frame.extend_from_slice(&len.to_be_bytes());
725        frame.extend_from_slice(&payload);
726
727        let mut cursor = std::io::Cursor::new(frame);
728        let msg = read_startup(&mut cursor).await.unwrap();
729        match msg {
730            FrontendMessage::Startup(params) => {
731                assert_eq!(params.get("user"), Some("alice"));
732            }
733            other => panic!("expected Startup, got {:?}", other),
734        }
735    }
736
737    #[tokio::test]
738    async fn parse_ssl_request() {
739        let mut frame: Vec<u8> = Vec::new();
740        frame.extend_from_slice(&8u32.to_be_bytes());
741        frame.extend_from_slice(&PG_SSL_REQUEST.to_be_bytes());
742        let mut cursor = std::io::Cursor::new(frame);
743        assert!(matches!(
744            read_startup(&mut cursor).await.unwrap(),
745            FrontendMessage::SslRequest
746        ));
747    }
748
749    #[tokio::test]
750    async fn parse_query_frame() {
751        let query = "SELECT 1\0";
752        let mut frame = Vec::new();
753        frame.push(b'Q');
754        let len = (4 + query.len()) as u32;
755        frame.extend_from_slice(&len.to_be_bytes());
756        frame.extend_from_slice(query.as_bytes());
757        let mut cursor = std::io::Cursor::new(frame);
758        match read_frame(&mut cursor).await.unwrap() {
759            FrontendMessage::Query(s) => assert_eq!(s, "SELECT 1"),
760            other => panic!("expected Query, got {:?}", other),
761        }
762    }
763
764    #[tokio::test]
765    async fn parse_extended_query_frames() {
766        let mut parse_payload = Vec::new();
767        push_test_cstring(&mut parse_payload, "");
768        push_test_cstring(&mut parse_payload, "SELECT $1");
769        parse_payload.extend_from_slice(&1i16.to_be_bytes());
770        parse_payload.extend_from_slice(&23u32.to_be_bytes());
771        let mut frame = tagged_frame(b'P', parse_payload);
772        let mut cursor = std::io::Cursor::new(frame);
773        match read_frame(&mut cursor).await.unwrap() {
774            FrontendMessage::Parse(msg) => {
775                assert_eq!(msg.statement, "");
776                assert_eq!(msg.query, "SELECT $1");
777                assert_eq!(msg.param_type_oids, vec![23]);
778            }
779            other => panic!("expected Parse, got {other:?}"),
780        }
781
782        let mut bind_payload = Vec::new();
783        push_test_cstring(&mut bind_payload, "");
784        push_test_cstring(&mut bind_payload, "");
785        bind_payload.extend_from_slice(&1i16.to_be_bytes());
786        bind_payload.extend_from_slice(&0i16.to_be_bytes());
787        bind_payload.extend_from_slice(&1i16.to_be_bytes());
788        bind_payload.extend_from_slice(&2i32.to_be_bytes());
789        bind_payload.extend_from_slice(b"42");
790        bind_payload.extend_from_slice(&0i16.to_be_bytes());
791        frame = tagged_frame(b'B', bind_payload);
792        let mut cursor = std::io::Cursor::new(frame);
793        match read_frame(&mut cursor).await.unwrap() {
794            FrontendMessage::Bind(msg) => {
795                assert_eq!(msg.portal, "");
796                assert_eq!(msg.statement, "");
797                assert_eq!(msg.param_format_codes, vec![0]);
798                assert_eq!(msg.params, vec![Some(b"42".to_vec())]);
799                assert!(msg.result_format_codes.is_empty());
800            }
801            other => panic!("expected Bind, got {other:?}"),
802        }
803
804        let mut describe_payload = vec![b'P'];
805        push_test_cstring(&mut describe_payload, "");
806        let mut cursor = std::io::Cursor::new(tagged_frame(b'D', describe_payload));
807        assert!(matches!(
808            read_frame(&mut cursor).await.unwrap(),
809            FrontendMessage::Describe(DescribeMessage {
810                target: DescribeTarget::Portal,
811                ..
812            })
813        ));
814    }
815
816    #[tokio::test]
817    async fn emit_ready_for_query() {
818        let mut out: Vec<u8> = Vec::new();
819        write_frame(
820            &mut out,
821            &BackendMessage::ReadyForQuery(TransactionStatus::Idle),
822        )
823        .await
824        .unwrap();
825        assert_eq!(out, vec![b'Z', 0, 0, 0, 5, b'I']);
826    }
827
828    #[tokio::test]
829    async fn emit_row_description_and_data_row() {
830        let mut out: Vec<u8> = Vec::new();
831        write_frame(
832            &mut out,
833            &BackendMessage::RowDescription(vec![ColumnDescriptor {
834                name: "id".to_string(),
835                table_oid: 0,
836                column_attr: 0,
837                type_oid: 23,
838                type_size: 4,
839                type_mod: -1,
840                format: 0,
841            }]),
842        )
843        .await
844        .unwrap();
845        assert_eq!(out[0], b'T');
846
847        let mut data: Vec<u8> = Vec::new();
848        write_frame(
849            &mut data,
850            &BackendMessage::DataRow(vec![Some(b"42".to_vec()), None]),
851        )
852        .await
853        .unwrap();
854        assert_eq!(data[0], b'D');
855    }
856
857    #[tokio::test]
858    async fn emit_extended_completion_frames() {
859        let mut out = Vec::new();
860        write_frame(&mut out, &BackendMessage::ParseComplete)
861            .await
862            .unwrap();
863        write_frame(&mut out, &BackendMessage::BindComplete)
864            .await
865            .unwrap();
866        write_frame(
867            &mut out,
868            &BackendMessage::ParameterDescription(vec![23, 25]),
869        )
870        .await
871        .unwrap();
872        write_frame(&mut out, &BackendMessage::NoData)
873            .await
874            .unwrap();
875        write_frame(&mut out, &BackendMessage::CloseComplete)
876            .await
877            .unwrap();
878        assert_eq!(collect_tags(&out), vec![b'1', b'2', b't', b'n', b'3']);
879    }
880
881    // ---------------------------------------------------------------
882    // F-02 (audit doc 2026-05-06): NUL-injection rejection in PG3
883    // C-string fields. Replacement codepoint U+FFFD is emitted
884    // instead of the raw NUL so the field cannot be terminated
885    // prematurely on the wire.
886    // ---------------------------------------------------------------
887
888    fn count_nul(buf: &[u8]) -> usize {
889        buf.iter().filter(|&&b| b == 0).count()
890    }
891
892    #[tokio::test]
893    async fn pg3_nul_error_response_message_field_sanitized() {
894        let mut out: Vec<u8> = Vec::new();
895        write_frame(
896            &mut out,
897            &BackendMessage::ErrorResponse {
898                severity: "ERROR".to_string(),
899                code: "42000".to_string(),
900                message: "smuggled\0M\x00injection".to_string(),
901            },
902        )
903        .await
904        .unwrap();
905        assert_eq!(out[0], b'E');
906        // ErrorResponse body: 4 inner C-string terminators (S/V/C/M)
907        // + 1 list-end terminator = 5 total NULs. The message field
908        // had 2 raw NULs in it; if not sanitized we'd see 7 NULs.
909        let body = &out[5..];
910        assert_eq!(
911            count_nul(body),
912            5,
913            "expected 5 NULs (4 field + 1 list-end), got {} :: body={:?}",
914            count_nul(body),
915            body
916        );
917        // U+FFFD must be present (EF BF BD).
918        assert!(
919            body.windows(3).any(|w| w == [0xEF, 0xBF, 0xBD]),
920            "expected U+FFFD substitution in body"
921        );
922    }
923
924    #[tokio::test]
925    async fn pg3_nul_notice_response_sanitized() {
926        let mut out: Vec<u8> = Vec::new();
927        write_frame(
928            &mut out,
929            &BackendMessage::NoticeResponse {
930                message: "evil\0field".to_string(),
931            },
932        )
933        .await
934        .unwrap();
935        assert_eq!(out[0], b'N');
936        let body = &out[5..];
937        // 2 inner C-string terminators (S, M) + 1 list-end = 3 NULs.
938        assert_eq!(count_nul(body), 3);
939        assert!(body.windows(3).any(|w| w == [0xEF, 0xBF, 0xBD]));
940    }
941
942    #[tokio::test]
943    async fn pg3_nul_command_complete_sanitized() {
944        let mut out: Vec<u8> = Vec::new();
945        write_frame(
946            &mut out,
947            &BackendMessage::CommandComplete("SELECT\0;DROP".to_string()),
948        )
949        .await
950        .unwrap();
951        assert_eq!(out[0], b'C');
952        let body = &out[5..];
953        // CommandComplete = single C-string + terminator -> 1 NUL.
954        assert_eq!(count_nul(body), 1);
955    }
956
957    #[tokio::test]
958    async fn pg3_nul_row_description_column_name_sanitized() {
959        let mut out: Vec<u8> = Vec::new();
960        write_frame(
961            &mut out,
962            &BackendMessage::RowDescription(vec![ColumnDescriptor {
963                name: "evil\0col".to_string(),
964                table_oid: 0,
965                column_attr: 0,
966                type_oid: 23,
967                type_size: 4,
968                type_mod: -1,
969                format: 0,
970            }]),
971        )
972        .await
973        .unwrap();
974        assert_eq!(out[0], b'T');
975        // The column-name region (after the i16 field count, before
976        // the OIDs) must contain exactly one terminator, not two.
977        let body = &out[5..];
978        // Skip 2 bytes (column count i16); next bytes up to the
979        // first NUL are the column name.
980        let name_region = &body[2..];
981        let first_nul = name_region.iter().position(|&b| b == 0).unwrap();
982        assert!(
983            name_region[..first_nul]
984                .windows(3)
985                .any(|w| w == [0xEF, 0xBF, 0xBD]),
986            "U+FFFD missing from sanitized column name"
987        );
988    }
989
990    #[test]
991    fn sanitize_cstring_fastpath_no_nul() {
992        let s = "no nuls here";
993        let out = sanitize_cstring_bytes(s.as_bytes());
994        assert_eq!(out, s.as_bytes());
995    }
996
997    #[test]
998    fn sanitize_cstring_substitutes_nul_with_replacement_codepoint() {
999        let s = b"a\0b\0c";
1000        let out = sanitize_cstring_bytes(s);
1001        // Each NUL becomes 3 bytes; total = 1 + 3 + 1 + 3 + 1 = 9.
1002        assert_eq!(out.len(), 9);
1003        assert!(!out.contains(&0));
1004        assert_eq!(&out[1..4], &[0xEF, 0xBF, 0xBD]);
1005        assert_eq!(&out[5..8], &[0xEF, 0xBF, 0xBD]);
1006    }
1007
1008    fn tagged_frame(tag: u8, payload: Vec<u8>) -> Vec<u8> {
1009        let mut frame = vec![tag];
1010        frame.extend_from_slice(&((payload.len() + 4) as u32).to_be_bytes());
1011        frame.extend_from_slice(&payload);
1012        frame
1013    }
1014
1015    fn push_test_cstring(out: &mut Vec<u8>, value: &str) {
1016        out.extend_from_slice(value.as_bytes());
1017        out.push(0);
1018    }
1019
1020    fn collect_tags(bytes: &[u8]) -> Vec<u8> {
1021        let mut tags = Vec::new();
1022        let mut pos = 0;
1023        while pos < bytes.len() {
1024            tags.push(bytes[pos]);
1025            let len = u32::from_be_bytes([
1026                bytes[pos + 1],
1027                bytes[pos + 2],
1028                bytes[pos + 3],
1029                bytes[pos + 4],
1030            ]) as usize;
1031            pos += 1 + len;
1032        }
1033        tags
1034    }
1035}