Skip to main content

sqlmodel_postgres/
connection.rs

1//! PostgreSQL connection implementation.
2//!
3//! This module implements the PostgreSQL wire protocol connection,
4//! including connection establishment, authentication, and state management.
5//!
6//! # Console Integration
7//!
8//! When the `console` feature is enabled, the connection can report progress
9//! during connection establishment. Use the `ConsoleAware` trait to attach
10//! a console for rich output.
11//!
12//! ```rust,ignore
13//! use sqlmodel_postgres::{PgConfig, PgConnection};
14//! use sqlmodel_console::{SqlModelConsole, ConsoleAware};
15//! use std::sync::Arc;
16//!
17//! let console = Arc::new(SqlModelConsole::new());
18//! let mut conn = PgConnection::connect(config)?;
19//! conn.set_console(Some(console));
20//! ```
21
22use std::collections::HashMap;
23use std::io::{Read, Write};
24use std::net::TcpStream;
25#[cfg(feature = "console")]
26use std::sync::Arc;
27
28use sqlmodel_core::Error;
29use sqlmodel_core::error::{
30    ConnectionError, ConnectionErrorKind, ProtocolError, QueryError, QueryErrorKind,
31};
32
33#[cfg(feature = "console")]
34use sqlmodel_console::{ConsoleAware, SqlModelConsole};
35
36use crate::auth::ScramClient;
37use crate::config::PgConfig;
38#[cfg(not(feature = "tls"))]
39use crate::config::SslMode;
40use crate::protocol::{
41    BackendMessage, ErrorFields, FrontendMessage, MessageReader, MessageWriter, PROTOCOL_VERSION,
42    TransactionStatus,
43};
44
45#[cfg(feature = "tls")]
46use crate::tls;
47
48// A TLS stream is ~1KB vs a few bytes for the other variants; boxing it
49// would add a pointer chase on every read/write of the hot I/O path.
50#[allow(clippy::large_enum_variant)]
51enum PgStream {
52    Plain(TcpStream),
53    #[cfg(feature = "tls")]
54    Tls(rustls::StreamOwned<rustls::ClientConnection, TcpStream>),
55    #[cfg(feature = "tls")]
56    Closed,
57}
58
59impl PgStream {
60    #[cfg(feature = "tls")]
61    fn read_exact(&mut self, buf: &mut [u8]) -> std::io::Result<()> {
62        match self {
63            PgStream::Plain(s) => s.read_exact(buf),
64            #[cfg(feature = "tls")]
65            PgStream::Tls(s) => s.read_exact(buf),
66            #[cfg(feature = "tls")]
67            PgStream::Closed => Err(std::io::Error::new(
68                std::io::ErrorKind::NotConnected,
69                "connection closed",
70            )),
71        }
72    }
73
74    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
75        match self {
76            PgStream::Plain(s) => s.read(buf),
77            #[cfg(feature = "tls")]
78            PgStream::Tls(s) => s.read(buf),
79            #[cfg(feature = "tls")]
80            PgStream::Closed => Err(std::io::Error::new(
81                std::io::ErrorKind::NotConnected,
82                "connection closed",
83            )),
84        }
85    }
86
87    fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
88        match self {
89            PgStream::Plain(s) => s.write_all(buf),
90            #[cfg(feature = "tls")]
91            PgStream::Tls(s) => s.write_all(buf),
92            #[cfg(feature = "tls")]
93            PgStream::Closed => Err(std::io::Error::new(
94                std::io::ErrorKind::NotConnected,
95                "connection closed",
96            )),
97        }
98    }
99
100    fn flush(&mut self) -> std::io::Result<()> {
101        match self {
102            PgStream::Plain(s) => s.flush(),
103            #[cfg(feature = "tls")]
104            PgStream::Tls(s) => s.flush(),
105            #[cfg(feature = "tls")]
106            PgStream::Closed => Err(std::io::Error::new(
107                std::io::ErrorKind::NotConnected,
108                "connection closed",
109            )),
110        }
111    }
112}
113
114/// Connection state in the PostgreSQL protocol state machine.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum ConnectionState {
117    /// Not connected
118    Disconnected,
119    /// TCP connection established, sending startup
120    Connecting,
121    /// Performing authentication handshake
122    Authenticating,
123    /// Ready for queries
124    Ready(TransactionStatusState),
125    /// Currently executing a query
126    InQuery,
127    /// In a transaction block
128    InTransaction(TransactionStatusState),
129    /// Connection is in an error state
130    Error,
131    /// Connection has been closed
132    Closed,
133}
134
135/// Transaction status from the server.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
137pub enum TransactionStatusState {
138    /// Not in a transaction block ('I')
139    #[default]
140    Idle,
141    /// In a transaction block ('T')
142    InTransaction,
143    /// In a failed transaction block ('E')
144    InFailed,
145}
146
147impl From<TransactionStatus> for TransactionStatusState {
148    fn from(status: TransactionStatus) -> Self {
149        match status {
150            TransactionStatus::Idle => TransactionStatusState::Idle,
151            TransactionStatus::Transaction => TransactionStatusState::InTransaction,
152            TransactionStatus::Error => TransactionStatusState::InFailed,
153        }
154    }
155}
156
157/// PostgreSQL connection.
158///
159/// Manages a TCP connection to a PostgreSQL server, handling the wire protocol,
160/// authentication, and state tracking.
161///
162/// # Console Support
163///
164/// When the `console` feature is enabled, the connection can report progress
165/// via an attached `SqlModelConsole`. This provides rich feedback during
166/// connection establishment and query execution.
167pub struct PgConnection {
168    /// TCP stream to the server
169    stream: PgStream,
170    /// Current connection state
171    state: ConnectionState,
172    /// Backend process ID (for query cancellation)
173    process_id: i32,
174    /// Secret key (for query cancellation)
175    secret_key: i32,
176    /// Server parameters received during startup
177    parameters: HashMap<String, String>,
178    /// Connection configuration
179    config: PgConfig,
180    /// Message reader for parsing backend messages
181    reader: MessageReader,
182    /// Message writer for encoding frontend messages
183    writer: MessageWriter,
184    /// Read buffer
185    read_buf: Vec<u8>,
186    /// Optional console for rich output
187    #[cfg(feature = "console")]
188    console: Option<Arc<SqlModelConsole>>,
189}
190
191impl std::fmt::Debug for PgConnection {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.debug_struct("PgConnection")
194            .field("state", &self.state)
195            .field("process_id", &self.process_id)
196            .field("host", &self.config.host)
197            .field("port", &self.config.port)
198            .field("database", &self.config.database)
199            .finish_non_exhaustive()
200    }
201}
202
203impl PgConnection {
204    /// Establish a new connection to the PostgreSQL server.
205    ///
206    /// This performs the complete connection handshake:
207    /// 1. TCP connection
208    /// 2. SSL negotiation (if configured)
209    /// 3. Startup message
210    /// 4. Authentication
211    /// 5. Receive server parameters and ReadyForQuery
212    #[allow(clippy::result_large_err)]
213    pub fn connect(config: PgConfig) -> Result<Self, Error> {
214        // 1. TCP connection with timeout
215        let stream = TcpStream::connect_timeout(
216            &config.socket_addr().parse().map_err(|e| {
217                Error::Connection(ConnectionError {
218                    kind: ConnectionErrorKind::Connect,
219                    message: format!("Invalid socket address: {}", e),
220                    source: None,
221                })
222            })?,
223            config.connect_timeout,
224        )
225        .map_err(|e| {
226            let kind = if e.kind() == std::io::ErrorKind::ConnectionRefused {
227                ConnectionErrorKind::Refused
228            } else {
229                ConnectionErrorKind::Connect
230            };
231            Error::Connection(ConnectionError {
232                kind,
233                message: format!("Failed to connect to {}: {}", config.socket_addr(), e),
234                source: Some(Box::new(e)),
235            })
236        })?;
237
238        // Set TCP options
239        stream.set_nodelay(true).ok();
240        stream.set_read_timeout(Some(config.connect_timeout)).ok();
241        stream.set_write_timeout(Some(config.connect_timeout)).ok();
242
243        let mut conn = Self {
244            stream: PgStream::Plain(stream),
245            state: ConnectionState::Connecting,
246            process_id: 0,
247            secret_key: 0,
248            parameters: HashMap::new(),
249            config,
250            reader: MessageReader::new(),
251            writer: MessageWriter::new(),
252            read_buf: vec![0u8; 8192],
253            #[cfg(feature = "console")]
254            console: None,
255        };
256
257        // 2. SSL negotiation (if configured)
258        if conn.config.ssl_mode.should_try_ssl() {
259            #[cfg(feature = "tls")]
260            conn.negotiate_ssl()?;
261
262            #[cfg(not(feature = "tls"))]
263            if conn.config.ssl_mode != SslMode::Prefer {
264                return Err(Error::Connection(ConnectionError {
265                    kind: ConnectionErrorKind::Ssl,
266                    message:
267                        "TLS requested but 'sqlmodel-postgres' was built without feature 'tls'"
268                            .to_string(),
269                    source: None,
270                }));
271            }
272        }
273
274        // 3. Send startup message
275        conn.send_startup()?;
276        conn.state = ConnectionState::Authenticating;
277
278        // 4. Handle authentication
279        conn.handle_auth()?;
280
281        // 5. Read remaining startup messages until ReadyForQuery
282        conn.read_startup_messages()?;
283
284        Ok(conn)
285    }
286
287    /// Get the current connection state.
288    pub fn state(&self) -> ConnectionState {
289        self.state
290    }
291
292    /// Check if the connection is ready for queries.
293    pub fn is_ready(&self) -> bool {
294        matches!(self.state, ConnectionState::Ready(_))
295    }
296
297    /// Get the backend process ID (for query cancellation).
298    pub fn process_id(&self) -> i32 {
299        self.process_id
300    }
301
302    /// Get the secret key (for query cancellation).
303    pub fn secret_key(&self) -> i32 {
304        self.secret_key
305    }
306
307    /// Get a server parameter value.
308    pub fn parameter(&self, name: &str) -> Option<&str> {
309        self.parameters.get(name).map(|s| s.as_str())
310    }
311
312    /// Get all server parameters.
313    pub fn parameters(&self) -> &HashMap<String, String> {
314        &self.parameters
315    }
316
317    /// Close the connection gracefully.
318    #[allow(clippy::result_large_err)]
319    pub fn close(&mut self) -> Result<(), Error> {
320        if matches!(
321            self.state,
322            ConnectionState::Closed | ConnectionState::Disconnected
323        ) {
324            return Ok(());
325        }
326
327        // Send Terminate message
328        self.send_message(&FrontendMessage::Terminate)?;
329        self.state = ConnectionState::Closed;
330        Ok(())
331    }
332
333    // ==================== SSL Negotiation ====================
334
335    #[allow(clippy::result_large_err)]
336    #[cfg(feature = "tls")]
337    fn negotiate_ssl(&mut self) -> Result<(), Error> {
338        // Send SSL request
339        self.send_message(&FrontendMessage::SSLRequest)?;
340
341        // Read single-byte response
342        let mut buf = [0u8; 1];
343        self.stream.read_exact(&mut buf).map_err(|e| {
344            Error::Connection(ConnectionError {
345                kind: ConnectionErrorKind::Ssl,
346                message: format!("Failed to read SSL response: {}", e),
347                source: Some(Box::new(e)),
348            })
349        })?;
350
351        match buf[0] {
352            b'S' => {
353                // Server supports SSL; upgrade to TLS.
354                #[cfg(feature = "tls")]
355                {
356                    let plain = match std::mem::replace(&mut self.stream, PgStream::Closed) {
357                        PgStream::Plain(s) => s,
358                        other => {
359                            self.stream = other;
360                            return Err(Error::Connection(ConnectionError {
361                                kind: ConnectionErrorKind::Ssl,
362                                message: "TLS upgrade requires a plain TCP stream".to_string(),
363                                source: None,
364                            }));
365                        }
366                    };
367
368                    let config = tls::build_client_config(self.config.ssl_mode)?;
369                    let server_name = tls::server_name(&self.config.host)?;
370                    let conn =
371                        rustls::ClientConnection::new(std::sync::Arc::new(config), server_name)
372                            .map_err(|e| {
373                                Error::Connection(ConnectionError {
374                                    kind: ConnectionErrorKind::Ssl,
375                                    message: format!("Failed to create TLS connection: {e}"),
376                                    source: None,
377                                })
378                            })?;
379
380                    let mut tls_stream = rustls::StreamOwned::new(conn, plain);
381                    while tls_stream.conn.is_handshaking() {
382                        tls_stream
383                            .conn
384                            .complete_io(&mut tls_stream.sock)
385                            .map_err(|e| {
386                                Error::Connection(ConnectionError {
387                                    kind: ConnectionErrorKind::Ssl,
388                                    message: format!("TLS handshake failed: {e}"),
389                                    source: Some(Box::new(e)),
390                                })
391                            })?;
392                    }
393
394                    self.stream = PgStream::Tls(tls_stream);
395                    Ok(())
396                }
397
398                #[cfg(not(feature = "tls"))]
399                {
400                    Err(Error::Connection(ConnectionError {
401                        kind: ConnectionErrorKind::Ssl,
402                        message:
403                            "TLS requested but 'sqlmodel-postgres' was built without feature 'tls'"
404                                .to_string(),
405                        source: None,
406                    }))
407                }
408            }
409            b'N' => {
410                // Server doesn't support SSL
411                if self.config.ssl_mode.is_required() {
412                    return Err(Error::Connection(ConnectionError {
413                        kind: ConnectionErrorKind::Ssl,
414                        message: "Server does not support SSL".to_string(),
415                        source: None,
416                    }));
417                }
418                // Continue without SSL (prefer mode)
419                Ok(())
420            }
421            _ => Err(Error::Connection(ConnectionError {
422                kind: ConnectionErrorKind::Ssl,
423                message: format!("Unexpected SSL response: 0x{:02x}", buf[0]),
424                source: None,
425            })),
426        }
427    }
428
429    // ==================== Startup ====================
430
431    #[allow(clippy::result_large_err)]
432    fn send_startup(&mut self) -> Result<(), Error> {
433        let params = self.config.startup_params();
434        let msg = FrontendMessage::Startup {
435            version: PROTOCOL_VERSION,
436            params,
437        };
438        self.send_message(&msg)
439    }
440
441    // ==================== Authentication ====================
442
443    #[allow(clippy::result_large_err)]
444    fn require_auth_value(&self, message: &'static str) -> Result<&str, Error> {
445        // NOTE: Auth values are sourced from runtime config, not hardcoded.
446        self.config
447            .password
448            .as_deref()
449            .ok_or_else(|| auth_error(message))
450    }
451
452    #[allow(clippy::result_large_err)]
453    fn handle_auth(&mut self) -> Result<(), Error> {
454        loop {
455            let msg = self.receive_message()?;
456
457            match msg {
458                BackendMessage::AuthenticationOk => {
459                    return Ok(());
460                }
461                BackendMessage::AuthenticationCleartextPassword => {
462                    let auth_value =
463                        self.require_auth_value("Authentication value required but not provided")?;
464                    self.send_message(&FrontendMessage::PasswordMessage(auth_value.to_string()))?;
465                }
466                BackendMessage::AuthenticationMD5Password(salt) => {
467                    let auth_value =
468                        self.require_auth_value("Authentication value required but not provided")?;
469                    let hash = md5_password(&self.config.user, auth_value, salt);
470                    self.send_message(&FrontendMessage::PasswordMessage(hash))?;
471                }
472                BackendMessage::AuthenticationSASL(mechanisms) => {
473                    if mechanisms.contains(&"SCRAM-SHA-256".to_string()) {
474                        self.scram_auth()?;
475                    } else {
476                        return Err(auth_error(format!(
477                            "Unsupported SASL mechanisms: {:?}",
478                            mechanisms
479                        )));
480                    }
481                }
482                BackendMessage::ErrorResponse(e) => {
483                    self.state = ConnectionState::Error;
484                    return Err(error_from_fields(&e));
485                }
486                _ => {
487                    return Err(Error::Protocol(ProtocolError {
488                        message: format!("Unexpected message during auth: {:?}", msg),
489                        raw_data: None,
490                        source: None,
491                    }));
492                }
493            }
494        }
495    }
496
497    #[allow(clippy::result_large_err)]
498    fn scram_auth(&mut self) -> Result<(), Error> {
499        let auth_value =
500            self.require_auth_value("Authentication value required for SCRAM-SHA-256")?;
501
502        let mut client = ScramClient::new(&self.config.user, auth_value);
503
504        // Send client-first message
505        let client_first = client.client_first();
506        self.send_message(&FrontendMessage::SASLInitialResponse {
507            mechanism: "SCRAM-SHA-256".to_string(),
508            data: client_first,
509        })?;
510
511        // Receive server-first
512        let msg = self.receive_message()?;
513        let server_first_data = match msg {
514            BackendMessage::AuthenticationSASLContinue(data) => data,
515            BackendMessage::ErrorResponse(e) => {
516                self.state = ConnectionState::Error;
517                return Err(error_from_fields(&e));
518            }
519            _ => {
520                return Err(Error::Protocol(ProtocolError {
521                    message: format!("Expected SASL continue, got: {:?}", msg),
522                    raw_data: None,
523                    source: None,
524                }));
525            }
526        };
527
528        // Generate and send client-final
529        let client_final = client.process_server_first(&server_first_data)?;
530        self.send_message(&FrontendMessage::SASLResponse(client_final))?;
531
532        // Receive server-final
533        let msg = self.receive_message()?;
534        let server_final_data = match msg {
535            BackendMessage::AuthenticationSASLFinal(data) => data,
536            BackendMessage::ErrorResponse(e) => {
537                self.state = ConnectionState::Error;
538                return Err(error_from_fields(&e));
539            }
540            _ => {
541                return Err(Error::Protocol(ProtocolError {
542                    message: format!("Expected SASL final, got: {:?}", msg),
543                    raw_data: None,
544                    source: None,
545                }));
546            }
547        };
548
549        // Verify server signature
550        client.verify_server_final(&server_final_data)?;
551
552        // Wait for AuthenticationOk
553        let msg = self.receive_message()?;
554        match msg {
555            BackendMessage::AuthenticationOk => Ok(()),
556            BackendMessage::ErrorResponse(e) => {
557                self.state = ConnectionState::Error;
558                Err(error_from_fields(&e))
559            }
560            _ => Err(Error::Protocol(ProtocolError {
561                message: format!("Expected AuthenticationOk, got: {:?}", msg),
562                raw_data: None,
563                source: None,
564            })),
565        }
566    }
567
568    // ==================== Startup Messages ====================
569
570    #[allow(clippy::result_large_err)]
571    fn read_startup_messages(&mut self) -> Result<(), Error> {
572        loop {
573            let msg = self.receive_message()?;
574
575            match msg {
576                BackendMessage::BackendKeyData {
577                    process_id,
578                    secret_key,
579                } => {
580                    self.process_id = process_id;
581                    self.secret_key = secret_key;
582                }
583                BackendMessage::ParameterStatus { name, value } => {
584                    self.parameters.insert(name, value);
585                }
586                BackendMessage::ReadyForQuery(status) => {
587                    self.state = ConnectionState::Ready(status.into());
588                    return Ok(());
589                }
590                BackendMessage::ErrorResponse(e) => {
591                    self.state = ConnectionState::Error;
592                    return Err(error_from_fields(&e));
593                }
594                BackendMessage::NoticeResponse(_notice) => {
595                    // Log but continue - notices are informational
596                }
597                _ => {
598                    return Err(Error::Protocol(ProtocolError {
599                        message: format!("Unexpected startup message: {:?}", msg),
600                        raw_data: None,
601                        source: None,
602                    }));
603                }
604            }
605        }
606    }
607
608    // ==================== Low-Level I/O ====================
609
610    #[allow(clippy::result_large_err)]
611    fn send_message(&mut self, msg: &FrontendMessage) -> Result<(), Error> {
612        let data = self.writer.write(msg);
613        self.stream.write_all(data).map_err(|e| {
614            self.state = ConnectionState::Error;
615            Error::Io(e)
616        })?;
617        self.stream.flush().map_err(|e| {
618            self.state = ConnectionState::Error;
619            Error::Io(e)
620        })?;
621        Ok(())
622    }
623
624    #[allow(clippy::result_large_err)]
625    fn receive_message(&mut self) -> Result<BackendMessage, Error> {
626        // Try to parse any complete messages from buffer first
627        loop {
628            match self.reader.next_message() {
629                Ok(Some(msg)) => return Ok(msg),
630                Ok(None) => {
631                    // Need more data
632                    let n = self.stream.read(&mut self.read_buf).map_err(|e| {
633                        if e.kind() == std::io::ErrorKind::TimedOut
634                            || e.kind() == std::io::ErrorKind::WouldBlock
635                        {
636                            Error::Timeout
637                        } else {
638                            self.state = ConnectionState::Error;
639                            Error::Connection(ConnectionError {
640                                kind: ConnectionErrorKind::Disconnected,
641                                message: format!("Failed to read from server: {}", e),
642                                source: Some(Box::new(e)),
643                            })
644                        }
645                    })?;
646
647                    if n == 0 {
648                        self.state = ConnectionState::Disconnected;
649                        return Err(Error::Connection(ConnectionError {
650                            kind: ConnectionErrorKind::Disconnected,
651                            message: "Connection closed by server".to_string(),
652                            source: None,
653                        }));
654                    }
655
656                    // Feed data to reader
657                    self.reader.feed(&self.read_buf[..n]).map_err(|e| {
658                        Error::Protocol(ProtocolError {
659                            message: format!("Protocol error: {}", e),
660                            raw_data: None,
661                            source: None,
662                        })
663                    })?;
664                }
665                Err(e) => {
666                    self.state = ConnectionState::Error;
667                    return Err(Error::Protocol(ProtocolError {
668                        message: format!("Protocol error: {}", e),
669                        raw_data: None,
670                        source: None,
671                    }));
672                }
673            }
674        }
675    }
676}
677
678impl Drop for PgConnection {
679    fn drop(&mut self) {
680        // Try to close gracefully, ignore errors
681        let _ = self.close();
682    }
683}
684
685// ==================== Console Support ====================
686
687#[cfg(feature = "console")]
688impl ConsoleAware for PgConnection {
689    fn set_console(&mut self, console: Option<Arc<SqlModelConsole>>) {
690        self.console = console;
691    }
692
693    fn console(&self) -> Option<&Arc<SqlModelConsole>> {
694        self.console.as_ref()
695    }
696
697    fn has_console(&self) -> bool {
698        self.console.is_some()
699    }
700}
701
702/// Connection progress stage for console output.
703#[cfg(feature = "console")]
704#[derive(Debug, Clone, Copy, PartialEq, Eq)]
705pub enum ConnectionStage {
706    /// Resolving DNS
707    DnsResolve,
708    /// Establishing TCP connection
709    TcpConnect,
710    /// Negotiating SSL/TLS
711    SslNegotiate,
712    /// SSL/TLS established
713    SslEstablished,
714    /// Sending startup message
715    Startup,
716    /// Authenticating
717    Authenticating,
718    /// Authentication complete
719    Authenticated,
720    /// Ready for queries
721    Ready,
722}
723
724#[cfg(feature = "console")]
725impl ConnectionStage {
726    /// Get a human-readable description of the stage.
727    #[must_use]
728    pub fn description(&self) -> &'static str {
729        match self {
730            Self::DnsResolve => "Resolving DNS",
731            Self::TcpConnect => "Connecting (TCP)",
732            Self::SslNegotiate => "Negotiating SSL",
733            Self::SslEstablished => "SSL established",
734            Self::Startup => "Sending startup",
735            Self::Authenticating => "Authenticating",
736            Self::Authenticated => "Authenticated",
737            Self::Ready => "Ready",
738        }
739    }
740}
741
742#[cfg(feature = "console")]
743impl PgConnection {
744    /// Emit a connection progress message to the console.
745    ///
746    /// This is a no-op if no console is attached.
747    pub fn emit_progress(&self, stage: ConnectionStage, success: bool) {
748        if let Some(console) = &self.console {
749            let status = if success { "[OK]" } else { "[..] " };
750            let message = format!("{} {}", status, stage.description());
751            console.info(&message);
752        }
753    }
754
755    /// Emit a connection success message with server info.
756    pub fn emit_connected(&self) {
757        if let Some(console) = &self.console {
758            let server_version = self
759                .parameters
760                .get("server_version")
761                .map_or("unknown", |s| s.as_str());
762            let message = format!(
763                "Connected to PostgreSQL {} at {}:{}",
764                server_version, self.config.host, self.config.port
765            );
766            console.success(&message);
767        }
768    }
769
770    /// Emit a plain-text connection summary (for agent mode).
771    pub fn emit_connected_plain(&self) -> String {
772        let server_version = self
773            .parameters
774            .get("server_version")
775            .map_or("unknown", |s| s.as_str());
776        format!(
777            "Connected to PostgreSQL {} at {}:{}",
778            server_version, self.config.host, self.config.port
779        )
780    }
781}
782
783// ==================== Helper Functions ====================
784
785/// Compute MD5 password hash as per PostgreSQL protocol.
786fn md5_password(user: &str, password: &str, salt: [u8; 4]) -> String {
787    use std::fmt::Write;
788
789    // md5(md5(password + user) + salt)
790    let inner = format!("{}{}", password, user);
791    let inner_hash = md5::compute(inner.as_bytes());
792
793    let mut outer_input = format!("{:x}", inner_hash).into_bytes();
794    outer_input.extend_from_slice(&salt);
795    let outer_hash = md5::compute(&outer_input);
796
797    let mut result = String::with_capacity(35);
798    result.push_str("md5");
799    write!(&mut result, "{:x}", outer_hash).unwrap();
800    result
801}
802
803fn auth_error(msg: impl Into<String>) -> Error {
804    Error::Connection(ConnectionError {
805        kind: ConnectionErrorKind::Authentication,
806        message: msg.into(),
807        source: None,
808    })
809}
810
811fn error_from_fields(fields: &ErrorFields) -> Error {
812    // Determine error kind from SQLSTATE
813    let kind = match fields.code.get(..2) {
814        Some("08") => {
815            // Connection exception
816            return Error::Connection(ConnectionError {
817                kind: ConnectionErrorKind::Connect,
818                message: fields.message.clone(),
819                source: None,
820            });
821        }
822        Some("28") => {
823            // Invalid authorization specification
824            return Error::Connection(ConnectionError {
825                kind: ConnectionErrorKind::Authentication,
826                message: fields.message.clone(),
827                source: None,
828            });
829        }
830        Some("42") => QueryErrorKind::Syntax, // Syntax error or access rule violation
831        Some("23") => QueryErrorKind::Constraint, // Integrity constraint violation
832        Some("40") => {
833            if fields.code == "40001" {
834                QueryErrorKind::Serialization
835            } else {
836                QueryErrorKind::Deadlock
837            }
838        }
839        Some("57") => {
840            if fields.code == "57014" {
841                QueryErrorKind::Cancelled
842            } else {
843                QueryErrorKind::Timeout
844            }
845        }
846        _ => QueryErrorKind::Database,
847    };
848
849    Error::Query(QueryError {
850        kind,
851        sql: None,
852        sqlstate: Some(fields.code.clone()),
853        message: fields.message.clone(),
854        detail: fields.detail.clone(),
855        hint: fields.hint.clone(),
856        position: fields.position.map(|p| p as usize),
857        source: None,
858    })
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864
865    #[test]
866    fn test_md5_password() {
867        // Example from PostgreSQL documentation
868        let hash = md5_password("postgres", "mysecretpassword", *b"abcd");
869        assert!(hash.starts_with("md5"));
870        assert_eq!(hash.len(), 35); // "md5" + 32 hex chars
871    }
872
873    #[test]
874    fn test_transaction_status_conversion() {
875        assert_eq!(
876            TransactionStatusState::from(TransactionStatus::Idle),
877            TransactionStatusState::Idle
878        );
879        assert_eq!(
880            TransactionStatusState::from(TransactionStatus::Transaction),
881            TransactionStatusState::InTransaction
882        );
883        assert_eq!(
884            TransactionStatusState::from(TransactionStatus::Error),
885            TransactionStatusState::InFailed
886        );
887    }
888
889    #[test]
890    fn test_error_classification() {
891        let fields = ErrorFields {
892            severity: "ERROR".to_string(),
893            code: "23505".to_string(),
894            message: "unique violation".to_string(),
895            ..Default::default()
896        };
897        let err = error_from_fields(&fields);
898        assert!(matches!(err, Error::Query(q) if q.kind == QueryErrorKind::Constraint));
899
900        let fields = ErrorFields {
901            severity: "FATAL".to_string(),
902            code: "28P01".to_string(),
903            message: "password authentication failed".to_string(),
904            ..Default::default()
905        };
906        let err = error_from_fields(&fields);
907        assert!(matches!(
908            err,
909            Error::Connection(c) if c.kind == ConnectionErrorKind::Authentication
910        ));
911    }
912}