packages feed

pg-wire-0.2.0.0: src/PgWire/Connection.hs

-- | PostgreSQL connection management.
--
-- A 'Connection' represents an active TCP (or TLS) session with a
-- PostgreSQL server. It handles authentication, parameter negotiation,
-- and prepared statement caching.
--
-- After startup, each connection spawns dedicated writer and reader
-- threads (see 'PgWire.Async') that enable automatic pipelining when
-- multiple green threads share a connection.
--
-- For production use, prefer 'PgWire.Pool' over direct connections.
--
-- @
-- conn <- 'connectString' \"postgres:\/\/user:pass\@localhost:5432\/mydb\"
-- (rows, _) <- 'simpleQuery' conn \"SELECT 1\"
-- 'close' conn
-- @
module PgWire.Connection
  ( -- * Connection type
    Connection (..)
  , connWire
    -- * Connecting
  , connect
  , connectString
  , reset
    -- * Closing
  , close
  , withConnection
    -- * Simple queries
  , simpleQuery
    -- * Connection status
  , connectionStatus
  , transactionStatus
  , parameterStatus
  , serverVersion
  , backendPid
  , isSslInUse
    -- * Notifications (non-blocking)
  , checkNotification
    -- * Notice handling
  , setNoticeHandler
    -- * SQL escaping
  , escapeLiteral
  , escapeIdentifier
    -- * Statement introspection
  , describePrepared
  , ParamDescription (..)
  , ColumnDescription (..)
    -- * Server ping
  , ping
    -- * Password utilities
  , lookupPgpass
  , encryptPassword
    -- * Protocol tracing
  , setTraceHandler
  ) where

import Control.Exception (SomeException, bracket, catch, try)
import Crypto.Hash (MD5, hash, Digest)
import Data.ByteArray qualified as BA
import Data.ByteString qualified as BS
import System.Directory (doesFileExist)
import System.Environment (lookupEnv)
import System.Random (randomRIO)
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as BS8
import Data.IORef
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Int (Int32)
import Data.Vector qualified as V
import Data.Vector (Vector)
import Data.Word (Word32, Word64)
import PgWire.Cache.Sieve (SieveCache, newSieveCache)
import PgWire.Async (AsyncWireConn (..), Request (..), Response (..), spawnAsyncWireConn, shutdownAsyncWireConn, submitRequest, submitExclusive)
import PgWire.Auth.Cleartext (cleartextAuth)
import PgWire.Auth.MD5 (md5Auth)
import PgWire.Auth.ScramSHA256 (scramAuth)
import PgWire.Connection.Config (ConnConfig (..), TargetSessionAttrs (..), TlsMode (..), parseConnString, parseHosts)
import PgWire.Error (PgWireError (..), throwPgWire)
import PgWire.Protocol.Backend
import PgWire.Protocol.Builders (buildStartup)
import PgWire.Protocol.Frontend (DescribeTarget (..), FrontendMsg (..), StartupParams (..))
import PgWire.Wire (TlsConfig (..), TraceDirection (..), WireConn (..), connectTcpTimeout, recvBackendMsg, sendFrontendMsg, sendRawBytes, upgradeTls)

-- | A connection to a PostgreSQL database.
--
-- After the initial handshake, dedicated writer and reader threads handle
-- all wire I/O. Use 'submitRequest' or the higher-level functions in
-- "Valiant.Execute" to interact with the connection.
data Connection = Connection
  { connAsync :: !AsyncWireConn
  -- ^ The async wire connection (writer + reader threads).
  , connConfig :: !ConnConfig
  , connBackendPid :: {-# UNPACK #-} !Int32
  , connBackendKey :: {-# UNPACK #-} !Int32
  , connStmtCache :: !(SieveCache ByteString ByteString)
  -- ^ Prepared statement cache (SIEVE eviction). Keys are SQL text,
  -- values are server-side statement names. Eviction is driven by
  -- per-entry visited bits and a sweep hand; lookups are O(1) and
  -- mutate only the visited bit, not the linked list.
  , connStmtCounter :: !(IORef Word64)
  , connSslActive :: !Bool
  , connPreparedStatements :: !Bool
  -- ^ Whether to use named prepared statements (default: True).
  -- Set to False for compatibility with PgBouncer in transaction mode.
  -- When False, all queries use the unnamed statement (parsed + discarded
  -- each time, never cached).
  , connMaxPreparedStatements :: !Int
  -- ^ Maximum number of prepared statements cached on this connection.
  }

-- | Access the underlying 'WireConn' for direct wire operations.
-- Only use this inside exclusive mode ('submitExclusive').
connWire :: Connection -> WireConn
connWire = awcWire . connAsync

-- | Access the transaction status IORef.
connTxStatus :: Connection -> IORef TxStatus
connTxStatus = awcTxStatus . connAsync

-- | Access the server parameters IORef.
connParams :: Connection -> IORef (Map ByteString ByteString)
connParams = awcParamStatus . connAsync

-- | Access the notice handler IORef.
connNoticeHandler :: Connection -> IORef (PgNotice -> IO ())
connNoticeHandler = awcNoticeHandler . connAsync

-- | Parameter type information returned by 'describePrepared'.
data ParamDescription = ParamDescription
  { pdOids :: Vector Word32
  -- ^ OIDs of each parameter's type (one per @$N@ placeholder).
  }
  deriving stock (Show, Eq)

-- | Column metadata returned by 'describePrepared'.
data ColumnDescription = ColumnDescription
  { cdFields :: Vector FieldInfo
  -- ^ Name, type OID, table OID, column number, and format for each result column.
  }
  deriving stock (Show, Eq)

-- | Connect using a 'ConnConfig'. Supports multi-host failover:
-- if @ccHost@ contains comma-separated hosts (@\"host1,host2\"@),
-- tries each in order until one succeeds. If @ccTargetSessionAttrs@
-- is set, verifies the server matches (e.g., primary vs standby).
connect :: ConnConfig -> IO Connection
connect cfg0 = do
  -- Auto-lookup password from ~/.pgpass if not provided
  cfg <- if BS8.null (ccPassword cfg0)
    then do
      mPw <- lookupPgpass cfg0
      pure $ case mPw of
        Just pw -> cfg0 { ccPassword = pw }
        Nothing -> cfg0
    else pure cfg0
  let hosts = parseHosts (ccHost cfg)
  orderedHosts <- if ccLoadBalanceHosts cfg
    then shuffleHosts hosts
    else pure hosts
  tryHosts cfg orderedHosts

tryHosts :: ConnConfig -> [String] -> IO Connection
tryHosts _ [] = throwPgWire (ConnectionError "All hosts failed")
tryHosts cfg [h] = connectSingleHost cfg { ccHost = h }
tryHosts cfg (h : hs) = do
  result <- try @SomeException (connectSingleHost cfg { ccHost = h })
  case result of
    Right conn -> do
      ok <- checkSessionAttrs conn (ccTargetSessionAttrs cfg)
      if ok
        then pure conn
        else do
          close conn
          tryHosts cfg hs
    Left _ -> tryHosts cfg hs

checkSessionAttrs :: Connection -> TargetSessionAttrs -> IO Bool
checkSessionAttrs _ SessionAny = pure True
checkSessionAttrs conn attr = do
  (rows, _) <- simpleQuery conn "SHOW transaction_read_only"
  case rows of
    [[Just val]] ->
      let readOnly = val == "on"
       in pure $ case attr of
            SessionReadWrite -> not readOnly
            SessionReadOnly -> readOnly
            SessionPrimary -> not readOnly
            SessionStandby -> readOnly
            SessionPreferStandby -> True
    _ -> pure True

-- | Fisher-Yates shuffle for load balancing
shuffleHosts :: [a] -> IO [a]
shuffleHosts [] = pure []
shuffleHosts [x] = pure [x]
shuffleHosts xs = do
  let arr = zip [0 :: Int ..] xs
      n = length xs
  go (reverse arr) (n - 1)
  where
    go [] _ = pure []
    go ((_, x) : _rest) 0 = pure [x]
    go items i = do
      j <- randomRIO (0, i)
      let (before, rest) = splitAt j items
      case rest of
        []                   -> pure (map snd before)  -- unreachable: j <= length items - 1
        (_, picked) : after  -> (picked :) <$> go (before ++ after) (i - 1)

connectSingleHost :: ConnConfig -> IO Connection
connectSingleHost cfg = do
  wc0 <- connectTcpTimeout (ccConnectTimeout cfg) (ccHost cfg) (ccPort cfg)

  let tlsCfg = TlsConfig
        { tlsHostname = ccHost cfg
        , tlsVerify = ccTls cfg `elem` [TlsVerifyCa, TlsVerifyFull]
        , tlsVerifyHostname = ccTls cfg == TlsVerifyFull
        , tlsClientCert = ccSslCert cfg
        , tlsClientKey = ccSslKey cfg
        , tlsCaCert = ccSslRootCert cfg
        }

  -- Optionally upgrade to TLS
  wc <- case ccTls cfg of
    TlsDisable -> pure wc0
    TlsRequire -> upgradeTls wc0 tlsCfg
    TlsVerifyCa -> upgradeTls wc0 tlsCfg
    TlsVerifyFull -> upgradeTls wc0 tlsCfg
    TlsPrefer -> do
      result <- try @SomeException (upgradeTls wc0 tlsCfg)
      case result of
        Right tlsWc -> pure tlsWc
        Left _ -> do
          wcClose wc0
          connectTcpTimeout (ccConnectTimeout cfg) (ccHost cfg) (ccPort cfg)

  -- Send startup message
  let extraParams =
        [ ("client_encoding", ccClientEncoding cfg) | ccClientEncoding cfg /= "UTF8" ]
        ++ [ ("options", ccOptions cfg) | not (BS8.null (ccOptions cfg)) ]
      startup =
        StartupParams
          { spUser = ccUser cfg
          , spDatabase = ccDatabase cfg
          , spAppName = ccAppName cfg
          , spExtraParams = extraParams
          }
  sendRawBytes wc (buildStartup startup)

  -- Handle authentication
  handleAuth wc cfg

  -- Collect server parameters until ReadyForQuery
  paramsRef <- newIORef Map.empty
  pidRef <- newIORef 0
  keyRef <- newIORef 0
  txRef <- newIORef TxIdle

  let loop = do
        msg <- recvBackendMsg wc
        case msg of
          ParameterStatus name value -> do
            modifyIORef' paramsRef (Map.insert name value)
            loop
          BackendKeyData pid key -> do
            writeIORef pidRef pid
            writeIORef keyRef key
            loop
          ReadyForQuery status -> do
            writeIORef txRef status
            pure ()
          NoticeResponse _ -> loop
          other ->
            throwPgWire (ProtocolError ("Unexpected message during startup: " <> BS8.pack (show other)))

  loop

  pid <- readIORef pidRef
  key <- readIORef keyRef
  let !cap = max 1 (ccMaxPreparedStatements cfg)
  stmtCache <- newSieveCache cap
  stmtCounter <- newIORef 0
  let sslActive = case ccTls cfg of
        TlsDisable -> False
        _ -> True

  -- Spawn async writer + reader threads
  asyncConn <- spawnAsyncWireConn wc txRef paramsRef

  pure
    Connection
      { connAsync = asyncConn
      , connConfig = cfg
      , connBackendPid = pid
      , connBackendKey = key
      , connStmtCache = stmtCache
      , connStmtCounter = stmtCounter
      , connSslActive = sslActive
      , connPreparedStatements = ccPreparedStatements cfg
      , connMaxPreparedStatements = cap
      }

-- | Connect using a connection string.
connectString :: ByteString -> IO Connection
connectString bs = case parseConnString bs of
  Left err -> throwPgWire (ConnectionError (BS8.pack err))
  Right cfg -> connect cfg

-- | Close a connection.
close :: Connection -> IO ()
close conn = do
  shutdownAsyncWireConn (connAsync conn)
  -- Send Terminate and close the socket (best effort, threads are already dead)
  sendFrontendMsg (connWire conn) Terminate `catch_` pure ()
  wcClose (connWire conn) `catch_` pure ()
  where
    catch_ :: IO a -> IO a -> IO a
    catch_ action fallback = action `catch` \(_ :: SomeException) -> fallback

-- | Bracket-style connection management.
withConnection :: ConnConfig -> (Connection -> IO a) -> IO a
withConnection cfg = bracket (connect cfg) close

-- | Execute a simple text query (no parameters). Returns all result rows
-- as lists of nullable bytestrings, plus the command tag.
simpleQuery :: Connection -> ByteString -> IO ([[Maybe ByteString]], Maybe CommandTag)
simpleQuery conn sql = do
  resp <- submitRequest (connAsync conn) (ReqSimpleQuery sql)
  case resp of
    RespSimple rows tag -> pure (rows, tag)
    _ -> throwPgWire (ProtocolError "simpleQuery: unexpected response type")

-- | Reset the connection: close and reconnect using the same config.
reset :: Connection -> IO Connection
reset conn = do
  close conn
  connect (connConfig conn)

-- Connection status -------------------------------------------------------

-- | Check if the connection is alive by sending an empty query.
connectionStatus :: Connection -> IO Bool
connectionStatus conn = do
  result <- try @SomeException (simpleQuery conn "")
  pure $ case result of
    Right _ -> True
    Left _ -> False

-- | Get the current transaction status.
transactionStatus :: Connection -> IO TxStatus
transactionStatus conn = readIORef (connTxStatus conn)

-- | Look up a server parameter (e.g., @\"server_version\"@, @\"server_encoding\"@).
parameterStatus :: Connection -> ByteString -> IO (Maybe ByteString)
parameterStatus conn key = do
  params <- readIORef (connParams conn)
  pure (Map.lookup key params)

-- | Get the server version as an integer (e.g., 160004 for 16.4).
-- Parses from the @server_version@ parameter.
serverVersion :: Connection -> IO (Maybe Int)
serverVersion conn = do
  mVersion <- parameterStatus conn "server_version"
  pure (mVersion >>= parseServerVersion)
  where
    parseServerVersion bs =
      -- Strip distribution suffix: "16.6 (Ubuntu 16.6-0ubuntu0.24.04.1)" → "16.6"
      let versionOnly = BS8.takeWhile (\c -> c == '.' || (c >= '0' && c <= '9')) bs
          parts = BS8.split '.' versionOnly
       in case parts of
            [major, minor] -> do
              maj <- readInt major
              mn <- readInt minor
              pure (maj * 10000 + mn)
            [major, minor, patch] -> do
              maj <- readInt major
              mn <- readInt minor
              p <- readInt patch
              pure (maj * 10000 + mn * 100 + p)
            _ -> Nothing
    readInt s = case BS8.readInt s of
      Just (n, _) -> Just n
      Nothing -> Nothing

-- | Get the backend process ID.
backendPid :: Connection -> Int32
backendPid = connBackendPid

-- | Check if SSL/TLS is in use on this connection.
isSslInUse :: Connection -> Bool
isSslInUse = connSslActive

-- Notifications (non-blocking) --------------------------------------------

-- | Check for a pending notification without blocking.
-- Returns 'Nothing' if no notification is available.
checkNotification :: Connection -> IO (Maybe (Int32, ByteString, ByteString))
checkNotification conn = do
  -- Send empty query to flush pending notifications from the server
  _ <- simpleQuery conn ""
  -- Notifications are dispatched by the reader thread to the handler.
  -- For non-blocking check, we'd need a different mechanism.
  -- For now, return Nothing — notifications arrive via the handler.
  pure Nothing

-- Notice handling ---------------------------------------------------------

-- | Set a callback for server notice messages (warnings, info, etc.).
-- The default handler discards all notices.
setNoticeHandler :: Connection -> (PgNotice -> IO ()) -> IO ()
setNoticeHandler conn handler = writeIORef (connNoticeHandler conn) handler

-- SQL escaping ------------------------------------------------------------

-- | Escape a string for use as a SQL literal. Returns a properly quoted
-- and escaped string including the surrounding single quotes.
--
-- @
-- escapeLiteral conn "O'Brien"  ==  "'O''Brien'"
-- @
escapeLiteral :: Connection -> ByteString -> ByteString
escapeLiteral _conn bs =
  "'" <> BS8.concatMap escapeChar bs <> "'"
  where
    escapeChar '\'' = "''"
    escapeChar '\\' = "\\\\"
    escapeChar c = BS8.singleton c

-- | Escape a string for use as a SQL identifier (table, column, function name).
-- Returns a properly quoted identifier with surrounding double quotes.
--
-- @
-- escapeIdentifier conn "user table"  ==  "\"user table\""
-- @
escapeIdentifier :: Connection -> ByteString -> ByteString
escapeIdentifier _conn bs =
  "\"" <> BS8.concatMap escapeChar bs <> "\""
  where
    escapeChar '"' = "\"\""
    escapeChar c = BS8.singleton c

-- Statement introspection -------------------------------------------------

-- | Describe a prepared statement. Returns parameter OIDs and column metadata.
-- The statement must already be prepared on this connection.
describePrepared :: Connection -> ByteString -> IO (ParamDescription, ColumnDescription)
describePrepared conn stmtName = do
  -- describePrepared needs direct wire access (Describe + Sync protocol)
  submitExclusive (connAsync conn) $ \wc txRef -> do
    sendFrontendMsg wc (Describe DescribeStatement stmtName)
    sendFrontendMsg wc Sync
    (params, cols) <- collectDescribe wc txRef
    pure (ParamDescription params, ColumnDescription cols)
  where
    collectDescribe wc txRef = do
      pds <- collectParams wc
      cds <- collectColumns wc
      waitDescribeReady wc txRef
      pure (pds, cds)

    collectParams wc = do
      msg <- recvBackendMsg wc
      case msg of
        ParameterDescription oids -> pure oids
        ErrorResponse err -> throwPgWire (QueryError err)
        _ -> collectParams wc

    collectColumns wc = do
      msg <- recvBackendMsg wc
      case msg of
        RowDescription fields -> pure fields
        NoData -> pure V.empty
        ErrorResponse err -> throwPgWire (QueryError err)
        _ -> collectColumns wc

    waitDescribeReady wc txRef = do
      msg <- recvBackendMsg wc
      case msg of
        ReadyForQuery status -> writeIORef txRef status
        _ -> waitDescribeReady wc txRef

-- Server ping -------------------------------------------------------------

-- | Check if a PostgreSQL server is accepting connections, without
-- fully authenticating. Attempts a TCP connection and checks if the
-- server responds to the startup sequence.
ping :: ConnConfig -> IO Bool
ping cfg = do
  result <- try @SomeException (connect cfg >>= close)
  pure $ case result of
    Right () -> True
    Left _ -> False

-- Password file -----------------------------------------------------------

-- | Look up a password from @~/.pgpass@ file.
-- Format: @hostname:port:database:username:password@ (one per line).
-- @*@ matches any value in a field.
lookupPgpass :: ConnConfig -> IO (Maybe ByteString)
lookupPgpass cfg = do
  home <- lookupEnv "HOME"
  case home of
    Nothing -> pure Nothing
    Just h -> do
      let path = h <> "/.pgpass"
      exists <- doesFileExist path
      if not exists
        then pure Nothing
        else do
          contents <- BS8.readFile path
          let host = BS8.pack (ccHost cfg)
              port = BS8.pack (show (ccPort cfg))
              db = ccDatabase cfg
              user = ccUser cfg
          pure (findMatch host port db user (BS8.lines contents))
  where
    findMatch _ _ _ _ [] = Nothing
    findMatch host port db user (line : rest)
      | BS8.null line || BS8.isPrefixOf "#" line = findMatch host port db user rest
      | otherwise = case BS8.split ':' line of
          [h, p, d, u, pw]
            | matches h host && matches p port && matches d db && matches u user ->
                Just pw
          _ -> findMatch host port db user rest
    matches "*" _ = True
    matches pattern value = pattern == value

-- Password encryption -----------------------------------------------------

-- | Encrypt a password for storage, using the same algorithm as
-- @PQencryptPasswordConn@. Supports MD5 format.
encryptPassword :: ByteString -> ByteString -> ByteString
encryptPassword user password =
  -- MD5 format: "md5" + hex(md5(password + user))
  let digest = BA.convert (hash (password <> user) :: Digest MD5) :: ByteString
   in "md5" <> toHex digest

toHex :: ByteString -> ByteString
toHex bs = BS8.pack (concatMap byteToHex (BS.unpack bs))
  where
    byteToHex w =
      let (hi, lo) = w `divMod` 16
       in [hexDigit hi, hexDigit lo]
    hexDigit n
      | n < 10 = toEnum (fromEnum '0' + fromIntegral n)
      | otherwise = toEnum (fromEnum 'a' + fromIntegral n - 10)

-- Protocol tracing --------------------------------------------------------

-- | Enable protocol tracing on this connection.
-- The callback receives 'True' for send, 'False' for recv, plus the raw bytes.
-- Set to @Nothing@ to disable tracing.
--
-- @
-- setTraceHandler conn $ \\isSend bytes ->
--   BS8.putStrLn $ (if isSend then \">>> \" else \"<<< \") <> BS8.take 40 bytes
-- @
setTraceHandler :: Connection -> (Bool -> ByteString -> IO ()) -> IO ()
setTraceHandler conn handler =
  writeIORef (wcTrace (connWire conn)) (Just wrapper)
  where
    wrapper TraceSend bs = handler True bs
    wrapper TraceRecv bs = handler False bs

-- Authentication ----------------------------------------------------------

handleAuth :: WireConn -> ConnConfig -> IO ()
handleAuth wc cfg = do
  msg <- recvBackendMsg wc
  case msg of
    Authentication AuthOk -> pure ()
    Authentication AuthCleartextPassword -> do
      cleartextAuth wc (ccPassword cfg)
      waitForAuthOk wc
    Authentication (AuthMD5Password salt) -> do
      md5Auth wc (ccUser cfg) (ccPassword cfg) salt
      waitForAuthOk wc
    Authentication (AuthSASL mechs)
      | "SCRAM-SHA-256" `elem` mechs -> do
          scramAuth wc (ccUser cfg) (ccPassword cfg)
          -- scramAuth handles waiting for AuthOk internally
      | otherwise ->
          throwPgWire (AuthError ("Unsupported SASL mechanisms: " <> BS8.pack (show mechs)))
    ErrorResponse err -> throwPgWire (AuthError (pgMessage err))
    other -> throwPgWire (AuthError ("Unexpected auth message: " <> BS8.pack (show other)))

waitForAuthOk :: WireConn -> IO ()
waitForAuthOk wc = do
  msg <- recvBackendMsg wc
  case msg of
    Authentication AuthOk -> pure ()
    ErrorResponse err -> throwPgWire (AuthError (pgMessage err))
    other -> throwPgWire (AuthError ("Expected AuthOk, got: " <> BS8.pack (show other)))