packages feed

pg-wire-0.1.0.0: src/PgWire/Wire.hs

-- | Low-level PostgreSQL wire protocol I\/O.
--
-- 'WireConn' abstracts over a TCP or TLS connection, providing
-- send\/recv operations for protocol messages. This module handles
-- message framing (tag + length + payload), buffering, and
-- TLS upgrades via the SSLRequest subprotocol.
--
-- Most users should use 'PgWire.Connection' instead of this module.
module PgWire.Wire
  ( -- * Wire connection
    WireConn (..)
    -- * Connecting
  , connectTcp
  , connectTcpTimeout
    -- * TLS
  , TlsConfig (..)
  , upgradeTls
    -- * Tracing
  , TraceDirection (..)
    -- * Sending messages
  , sendFrontendMsg
  , sendFrontendMsgs
  , sendRawBytes
    -- * Receiving messages
  , recvBackendMsg
  ) where

import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BS8
import Data.ByteString.Unsafe qualified as BU
import Data.ByteString.Builder qualified as B
import Data.ByteString.Lazy qualified as LBS
import Data.IORef
import Data.Maybe (fromMaybe)
import Data.Time (NominalDiffTime)
import PgWire.Error (PgWireError (..), throwPgWire)
import System.Timeout (timeout)
import PgWire.Protocol.Backend (BackendMsg)
import PgWire.Protocol.Builders (buildFrontendMsg, buildFrontendMsgsConcat)
import PgWire.Protocol.Frontend (FrontendMsg)
import PgWire.Protocol.Parsers (parseBackendMsg)
import Network.Socket (Socket)
import Network.Socket qualified as NS
import Network.Socket.ByteString qualified as NSB
import Network.Socket (setSocketOption, SocketOption(..))
import Network.TLS qualified as TLS
import Network.TLS (ClientParams (..), ClientHooks (..), Supported (..), Shared (..), Credentials (..))
import Data.X509.CertificateStore (readCertificateStore)
import System.X509 (getSystemCertificateStore)

-- | Abstraction over a Postgres wire connection (plain TCP or TLS).
-- | Direction for protocol trace callback.
data TraceDirection = TraceSend | TraceRecv
  deriving stock (Show, Eq)

data WireConn = WireConn
  { wcSend :: ByteString -> IO ()
  , wcSendMany :: [ByteString] -> IO ()
  -- ^ Vectored I/O: send multiple chunks in one syscall (writev).
  , wcRecv :: Int -> IO ByteString
  , wcClose :: IO ()
  , wcBuffer :: IORef ByteString
  , wcTrace :: IORef (Maybe (TraceDirection -> ByteString -> IO ()))
  }

-- | Connect via TCP to the given host and port (no timeout).
connectTcp :: NS.HostName -> NS.PortNumber -> IO WireConn
connectTcp = connectTcpTimeout 0

-- | Connect via TCP with a timeout in seconds (0 = no timeout).
connectTcpTimeout :: NominalDiffTime -> NS.HostName -> NS.PortNumber -> IO WireConn
connectTcpTimeout timeoutSecs host port = do
  let hints = NS.defaultHints {NS.addrSocketType = NS.Stream}
  addrs <- NS.getAddrInfo (Just hints) (Just host) (Just (show port))
  case addrs of
    [] -> throwPgWire (ConnectionError "No address found")
    (addr : _) -> do
      sock <- NS.socket (NS.addrFamily addr) NS.Stream NS.defaultProtocol
      let doConnect = NS.connect sock (NS.addrAddress addr)
      if timeoutSecs > 0
        then do
          let micros = round (timeoutSecs * 1000000) :: Int
          result <- timeout micros doConnect
          case result of
            Nothing -> do
              NS.close sock
              throwPgWire (ConnectionError "Connect timed out")
            Just () -> pure ()
        else doConnect
      -- Disable Nagle's algorithm for lower latency on small messages
      setSocketOption sock NoDelay 1
      mkWireConn sock

mkWireConn :: Socket -> IO WireConn
mkWireConn sock = do
  buf <- newIORef BS.empty
  traceRef <- newIORef Nothing
  pure
    WireConn
      { wcSend = sendAll sock
      , wcSendMany = NSB.sendMany sock
      , wcRecv = recvExact sock buf
      , wcClose = NS.close sock
      , wcBuffer = buf
      , wcTrace = traceRef
      }

-- | TLS configuration for upgradeTls.
data TlsConfig = TlsConfig
  { tlsHostname :: NS.HostName
  , tlsVerify :: Bool
  -- ^ Whether to verify the server certificate (verify-ca / verify-full)
  , tlsVerifyHostname :: Bool
  -- ^ Whether to verify hostname matches cert (verify-full only)
  , tlsClientCert :: Maybe FilePath
  -- ^ Path to client certificate file
  , tlsClientKey :: Maybe FilePath
  -- ^ Path to client private key file
  , tlsCaCert :: Maybe FilePath
  -- ^ Path to CA certificate file (Nothing = use system store)
  }

-- | Upgrade a TCP WireConn to TLS. Sends the SSLRequest message,
-- checks the server response, and performs the TLS handshake.
-- Returns a new WireConn that sends/receives over TLS.
upgradeTls :: WireConn -> TlsConfig -> IO WireConn
upgradeTls wc tlsCfg = do
  let sslRequest = LBS.toStrict . B.toLazyByteString $
        B.int32BE 8 <> B.int32BE 80877103
  wcSend wc sslRequest

  resp <- wcRecv wc 1
  case BS.index resp 0 of
    83 {- S -} -> do
      -- Load CA certificate store
      systemStore <- getSystemCertificateStore
      caStore <- case tlsCaCert tlsCfg of
        Nothing -> pure systemStore
        Just "system" -> pure systemStore
        Just path -> do
          mStore <- readCertificateStore path
          pure (fromMaybe systemStore mStore)

      -- Load client certificate + key if provided
      clientCred <- case (tlsClientCert tlsCfg, tlsClientKey tlsCfg) of
        (Just certPath, Just keyPath) -> do
          result <- TLS.credentialLoadX509 certPath keyPath
          case result of
            Right cred -> pure (Credentials [cred])
            Left _err -> do
              -- Non-fatal: warn but continue without client cert
              pure (Credentials [])
        _ -> pure (Credentials [])

      let hostname = tlsHostname tlsCfg
          baseParams = TLS.defaultParamsClient hostname ""
          -- Configure certificate validation based on mode
          hooks = (clientHooks baseParams)
            { onServerCertificate =
                if tlsVerify tlsCfg
                  then onServerCertificate (clientHooks baseParams)  -- default validation
                  else \_ _ _ _ -> pure []  -- skip validation (sslmode=require)
            }
          clientParams = baseParams
            { clientSupported = (clientSupported baseParams)
                { supportedVersions = [TLS.TLS13, TLS.TLS12]
                }
            , clientShared = (clientShared baseParams)
                { sharedCAStore = caStore
                , sharedCredentials = clientCred
                }
            , clientHooks = hooks
            }
      ctx <- TLS.contextNew (wireBackend wc) clientParams
      TLS.handshake ctx
      mkTlsWireConn ctx (wcBuffer wc)

    78 {- N -} ->
      throwPgWire (ConnectionError "Server does not support SSL")

    other ->
      throwPgWire (ConnectionError ("Unexpected SSL response: " <> BS8.pack (show other)))

-- | Wrap a WireConn as a TLS backend (for the handshake, which needs
-- raw socket I/O).
wireBackend :: WireConn -> TLS.Backend
wireBackend wc = TLS.Backend
  { TLS.backendFlush = pure ()
  , TLS.backendClose = wcClose wc
  , TLS.backendSend = wcSend wc
  , TLS.backendRecv = \n -> do
      -- TLS library expects exactly n bytes
      wcRecv wc n
  }

mkTlsWireConn :: TLS.Context -> IORef ByteString -> IO WireConn
mkTlsWireConn ctx bufRef = do
  writeIORef bufRef BS.empty
  tlsBuf <- newIORef BS.empty
  traceRef <- newIORef Nothing
  let tlsSend = TLS.sendData ctx . LBS.fromStrict
  pure
    WireConn
      { wcSend = tlsSend
      , wcSendMany = \chunks -> tlsSend (BS.concat chunks)
      , wcRecv = tlsRecvExact ctx tlsBuf
      , wcClose = TLS.bye ctx >> TLS.contextClose ctx
      , wcBuffer = tlsBuf
      , wcTrace = traceRef
      }

-- | Receive exactly n bytes from a TLS context, buffering leftovers.
-- Same optimization as 'recvExact': fast path for buffered data, Builder
-- accumulation for multi-chunk reads.
tlsRecvExact :: TLS.Context -> IORef ByteString -> Int -> IO ByteString
tlsRecvExact ctx bufRef n = do
  buf <- readIORef bufRef
  if BS.length buf >= n
    then do
      let (!taken, !rest) = BS.splitAt n buf
      writeIORef bufRef rest
      pure taken
    else if BS.null buf
      then do
        chunk <- TLS.recvData ctx
        if BS.null chunk
          then throwPgWire (ConnectionError "TLS connection closed")
          else if BS.length chunk >= n
            then do
              let (!taken, !rest) = BS.splitAt n chunk
              writeIORef bufRef rest
              pure taken
            else tlsAccumulate ctx bufRef chunk (n - BS.length chunk) (B.byteString chunk)
      else do
        let !remaining = n - BS.length buf
            !builder0 = B.byteString buf
        chunk <- TLS.recvData ctx
        if BS.null chunk
          then throwPgWire (ConnectionError "TLS connection closed")
          else if BS.length chunk >= remaining
            then do
              let (!taken, !rest) = BS.splitAt remaining chunk
              writeIORef bufRef rest
              pure (LBS.toStrict (B.toLazyByteString (builder0 <> B.byteString taken)))
            else tlsAccumulate ctx bufRef chunk (remaining - BS.length chunk) (builder0 <> B.byteString chunk)

tlsAccumulate :: TLS.Context -> IORef ByteString -> ByteString -> Int -> B.Builder -> IO ByteString
tlsAccumulate ctx bufRef chunk remaining !builder
  | BS.length chunk >= remaining = do
      let (!taken, !rest) = BS.splitAt remaining chunk
      writeIORef bufRef rest
      pure (LBS.toStrict (B.toLazyByteString (builder <> B.byteString taken)))
  | otherwise = do
      let !remaining' = remaining - BS.length chunk
          !builder' = builder <> B.byteString chunk
      next <- TLS.recvData ctx
      if BS.null next
        then throwPgWire (ConnectionError "TLS connection closed")
        else tlsAccumulate ctx bufRef next remaining' builder'

-- | Send a frontend message over the wire.
sendFrontendMsg :: WireConn -> FrontendMsg -> IO ()
sendFrontendMsg wc msg = do
  let bytes = buildFrontendMsg msg
  traceIfEnabled wc TraceSend bytes
  wcSend wc bytes
{-# INLINE sendFrontendMsg #-}

-- | Send multiple frontend messages in a single allocation and syscall.
-- All messages are fused into one 'Builder', materialized once with
-- 'buildFrontendMsgsConcat', and sent with a single @send()@.
sendFrontendMsgs :: WireConn -> [FrontendMsg] -> IO ()
sendFrontendMsgs wc msgs = do
  let !bytes = buildFrontendMsgsConcat msgs
  traceIfEnabled wc TraceSend bytes
  wcSend wc bytes
{-# INLINE sendFrontendMsgs #-}

-- | Send raw bytes (for startup message which has a different format).
sendRawBytes :: WireConn -> ByteString -> IO ()
sendRawBytes wc bs = do
  traceIfEnabled wc TraceSend bs
  wcSend wc bs

-- | Call the trace handler if one is set.
traceIfEnabled :: WireConn -> TraceDirection -> ByteString -> IO ()
traceIfEnabled wc dir bytes = do
  mHandler <- readIORef (wcTrace wc)
  case mHandler of
    Nothing -> pure ()
    Just handler -> handler dir bytes
{-# INLINE traceIfEnabled #-}

-- | Receive and parse a single backend message.
recvBackendMsg :: WireConn -> IO BackendMsg
recvBackendMsg wc = do
  -- Read tag (1 byte) + length (4 bytes) together in a single recv
  header <- wcRecv wc 5
  let !tag = BU.unsafeIndex header 0
      !len = decodeInt32At header 1
      !payloadLen = len - 4
  -- Read payload
  payload <-
    if payloadLen > 0
      then wcRecv wc (fromIntegral payloadLen)
      else if payloadLen < 0
        then throwPgWire (ProtocolError ("Invalid message length: " <> BS8.pack (show len)))
        else pure BS.empty
  -- Trace the raw bytes (header + payload)
  traceIfEnabled wc TraceRecv (header <> payload)
  case parseBackendMsg tag payload of
    Left err -> throwPgWire (ProtocolError (BS8.pack err))
    Right msg -> pure msg
  where
    decodeInt32At :: ByteString -> Int -> Int
    decodeInt32At bs off =
      let !b0 = fromIntegral (BU.unsafeIndex bs off)
          !b1 = fromIntegral (BU.unsafeIndex bs (off + 1))
          !b2 = fromIntegral (BU.unsafeIndex bs (off + 2))
          !b3 = fromIntegral (BU.unsafeIndex bs (off + 3))
       in b0 * 16777216 + b1 * 65536 + b2 * 256 + b3

-- Socket helpers ----------------------------------------------------------

sendAll :: Socket -> ByteString -> IO ()
sendAll sock bs
  | BS.null bs = pure ()
  | otherwise = do
      sent <- NSB.send sock bs
      sendAll sock (BS.drop sent bs)

-- | Socket recv buffer size. Larger values reduce syscalls for big result sets.
recvChunkSize :: Int
recvChunkSize = 32768
{-# INLINE recvChunkSize #-}

-- | Receive exactly @n@ bytes, using the buffer for leftovers.
-- Fast path: if the buffer already has enough data, just splitAt (zero allocation
-- beyond the two slices). Slow path: accumulate chunks via Builder to avoid
-- the O(n) reverse + concat of a list accumulator.
recvExact :: Socket -> IORef ByteString -> Int -> IO ByteString
recvExact sock bufRef n = do
  buf <- readIORef bufRef
  if BS.length buf >= n
    then do
      -- Fast path: buffer has enough data, no syscall needed.
      let (!taken, !rest) = BS.splitAt n buf
      writeIORef bufRef rest
      pure taken
    else if BS.null buf
      then do
        -- Buffer empty: read fresh chunk from socket.
        chunk <- NSB.recv sock (max recvChunkSize n)
        if BS.null chunk
          then throwPgWire (ConnectionError "Connection closed by server")
          else if BS.length chunk >= n
            then do
              let (!taken, !rest) = BS.splitAt n chunk
              writeIORef bufRef rest
              pure taken
            else accumulate chunk (n - BS.length chunk) (B.byteString chunk)
      else do
        -- Buffer has partial data: use it and read more.
        let !remaining = n - BS.length buf
            !builder0 = B.byteString buf
        chunk <- NSB.recv sock (max recvChunkSize remaining)
        if BS.null chunk
          then throwPgWire (ConnectionError "Connection closed by server")
          else if BS.length chunk >= remaining
            then do
              let (!taken, !rest) = BS.splitAt remaining chunk
              writeIORef bufRef rest
              pure (LBS.toStrict (B.toLazyByteString (builder0 <> B.byteString taken)))
            else accumulate chunk (remaining - BS.length chunk) (builder0 <> B.byteString chunk)
  where
    -- Slow path: need multiple recv calls. Uses Builder for O(1) append.
    accumulate :: ByteString -> Int -> B.Builder -> IO ByteString
    accumulate chunk remaining !builder
      | BS.length chunk >= remaining = do
          let (!taken, !rest) = BS.splitAt remaining chunk
          writeIORef bufRef rest
          pure (LBS.toStrict (B.toLazyByteString (builder <> B.byteString taken)))
      | otherwise = do
          let !remaining' = remaining - BS.length chunk
              !builder' = builder <> B.byteString chunk
          next <- NSB.recv sock (max recvChunkSize remaining')
          if BS.null next
            then throwPgWire (ConnectionError "Connection closed by server")
            else accumulate next remaining' builder'