packages feed

hpgsql-0.2.0.1: src/Hpgsql/Msgs.hs

module Hpgsql.Msgs (AuthenticationResponse (..), AuthenticationMethod (..), BackendKeyData (..), Bind (..), BindComplete (..), CancelRequest (..), CommandComplete (..), CopyData (..), CopyDone (..), CopyFail (..), CopyInResponse (..), DataRow (..), Describe (..), ErrorDetail (..), ErrorResponse (..), Execute (..), Flush (..), NoData (..), ParameterStatus (..), Query (..), ReadyForQuery (..), RowDescription (..), SASLInitialResponse (..), SASLResponse (..), StartupMessage (..), ToPgMessage (..), FromPgMessage (..), PgMsgParser (..), Terminate (..), TransactionStatus (..), NoticeResponse (..), NotificationResponse (..), Parse (..), ParseComplete (..), PasswordMessage (..), Sync (..), parsePgMessage, nulTermCString) where

import Control.Applicative (Alternative (..))
import Control.Monad (replicateM)
import qualified Crypto.Hash as Crypto
import qualified Data.Attoparsec.ByteString as Parsec
import qualified Data.Attoparsec.ByteString.Lazy as LazyParsec
import qualified Data.Attoparsec.Text as TextParsec
import Data.Bifunctor (first)
import Data.ByteArray (convert)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.ByteString.Internal (w2c)
import qualified Data.ByteString.Lazy as LBS
import Data.Functor (void)
import Data.Int (Int16, Int32)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe, mapMaybe)
import qualified Data.Serialize as Cereal
import Data.Text (Text)
import Data.Text.Encoding (decodeASCII, decodeUtf8, encodeUtf8)
import Data.Word (Word8)
import Hpgsql.Builder (BinaryField, Builder, builderLength)
import qualified Hpgsql.Builder as Builder
import Hpgsql.InternalTypes (BindComplete (..), CommandComplete (..), CopyInResponse (..), DataRow (..), ErrorDetail (..), ErrorResponse (..), NoData (..), NotificationResponse (..), ParseComplete (..), ReadyForQuery (..), RowDescription (..), TransactionStatus (..))
import Hpgsql.ScramSHA256 (ScramClientFinalMessage (..), ScramServerFirstMessage (..))
import Hpgsql.TypeInfo (Oid (..))

class ToPgMessage a where
  toPgMessage :: a -> Builder

newtype PgMsgParser a
  = PgMsgParser
      ( Char ->
        -- \| Message contents after the Int32 length attribute
        LBS.ByteString ->
        Maybe a
      )
  deriving stock (Functor)

instance Applicative PgMsgParser where
  pure a = PgMsgParser $ \_ _ -> Just a

  -- TODO: Is this Applicative correct? Double-check laws
  PgMsgParser f <*> PgMsgParser p = PgMsgParser $ \c r -> f c r <*> p c r

instance Alternative PgMsgParser where
  empty = PgMsgParser $ \_ _ -> Nothing
  PgMsgParser p1 <|> PgMsgParser p2 = PgMsgParser $ \c restOfMsg -> p1 c restOfMsg <|> p2 c restOfMsg

class FromPgMessage a where
  msgParser :: PgMsgParser a

parsePgMessage :: Char -> LBS.ByteString -> PgMsgParser a -> Maybe a
parsePgMessage c restOfMsg (PgMsgParser parseFunc) = parseFunc c restOfMsg

colParser :: Parsec.Parser (Text, Oid)
colParser = do
  colName <- nulTerminatedCStringParser -- Column name as C string
  void $ Parsec.take (4 + 2)
  -- TODO: OIDs are unsigned integers! Try `select (-1)::oid` to see. Change to UInt32 somehow
  typOid <- either fail pure . Cereal.decode @Int32 =<< Parsec.take 4
  void $ Parsec.take (2 + 4 + 2)
  pure (colName, Oid (fromIntegral typOid))

nulTerminatedCStringParser :: Parsec.Parser Text
nulTerminatedCStringParser = do
  stringWithoutNul <- Parsec.takeWhile (/= 0)
  Parsec.skip (== 0)
  pure $ decodeUtf8 stringWithoutNul

newtype AuthenticationResponse = AuthenticationResponse AuthenticationMethod
  deriving stock (Show)

data AuthenticationMethod = AuthOk | AuthKerberosV5 | AuthCleartextPassword | AuthMD5Password LBS.ByteString | AuthGSS | AuthGSSContinue | AuthSSPI | AuthSASL [Text] | AuthSASLContinue !ScramServerFirstMessage | AuthSASLFinal ByteString
  deriving stock (Show)

data SASLInitialResponse = SASLInitialResponse Text ByteString
  deriving stock (Show)

newtype SASLResponse = SASLResponse ScramClientFinalMessage
  deriving stock (Show)

data PasswordMessage
  = -- | Two last are username and password
    PasswordMessage AuthenticationMethod String String
  deriving stock (Show)

data BackendKeyData = BackendKeyData {backendPid :: !Int32, backendSecretKey :: !BS.ByteString}
  deriving stock (Show)

data Bind = Bind {paramsValuesInOrder :: ![BinaryField], resultColumnFmts :: !Int, preparedStmtHash :: !(Maybe String)}
  deriving stock (Show)

-- | PId first, secret key second
data CancelRequest = CancelRequest Int32 ByteString
  deriving stock (Show)

newtype CopyData = CopyData Builder

instance Show CopyData where
  show _ = "CopyData"

data Describe = Describe
  deriving stock (Show)

data Execute = Execute
  deriving stock (Show)

data CopyDone = CopyDone
  deriving stock (Show)

newtype CopyFail = CopyFail {causeForFailure :: String}
  deriving stock (Show)

data Flush = Flush
  deriving stock (Show)

newtype NoticeResponse = NoticeResponse (Map ErrorDetail LBS.ByteString)
  deriving stock (Show)

newtype Query = Query ByteString

instance Show Query where
  show _ = "Query"

data Parse = Parse {queryString :: !ByteString, specifiedParameterTypes :: ![Maybe Oid], preparedStmtHash :: !(Maybe String)}

instance Show Parse where
  show Parse {..} = "Parse (" ++ show (length specifiedParameterTypes) ++ " params specified) - " ++ show queryString

data ParameterStatus = ParameterStatus {parameterName :: !Text, parameterValue :: !Text}
  deriving stock (Show)

data StartupMessage = StartupMessage {user :: String, database :: String, options :: String}
  deriving stock (Show)

data Sync = Sync
  deriving stock (Show)

data Terminate = Terminate
  deriving stock (Show)

instance FromPgMessage AuthenticationResponse where
  msgParser = PgMsgParser $ \c restOfMsg -> case c of
    'R' -> case first (Cereal.decodeLazy @Int32) $ LBS.splitAt 4 restOfMsg of
      (Right 0, _) -> Just $ AuthenticationResponse AuthOk
      (Right 2, _) -> Just $ AuthenticationResponse AuthKerberosV5
      (Right 3, _) -> Just $ AuthenticationResponse AuthCleartextPassword
      (Right 5, salt) -> Just $ AuthenticationResponse (AuthMD5Password salt)
      (Right 7, _) -> Just $ AuthenticationResponse AuthGSS
      (Right 9, _) -> Just $ AuthenticationResponse AuthSSPI
      (Right 10, saslMechanismName) -> case Parsec.parseOnly (Parsec.many1 nulTerminatedCStringParser) (LBS.toStrict saslMechanismName) of
        Right sm -> Just $ AuthenticationResponse (AuthSASL sm)
        Left _ -> Nothing
      (Right 11, saslData) -> Just $ AuthenticationResponse (AuthSASLContinue (ScramServerFirstMessage $ LBS.toStrict saslData))
      (Right 12, saslData) -> Just $ AuthenticationResponse (AuthSASLFinal (LBS.toStrict saslData))
      _ -> Nothing
    _ -> Nothing

instance FromPgMessage BackendKeyData where
  msgParser = PgMsgParser $ \c (LBS.splitAt 4 -> (pidBS, backendSecretKey)) -> case c of
    'K' -> case Cereal.decodeLazy @Int32 pidBS of
      Right pid -> Just $ BackendKeyData {backendPid = pid, backendSecretKey = LBS.toStrict backendSecretKey}
      Left _ -> Nothing
    _ -> Nothing

instance FromPgMessage BindComplete where
  msgParser = PgMsgParser $ \c _restOfMsg -> case c of
    '2' -> Just BindComplete
    _ -> Nothing

instance FromPgMessage CommandComplete where
  msgParser = PgMsgParser $ \c restOfMsg -> case c of
    'C' ->
      let astext = decodeASCII $ LBS.toStrict $ LBS.dropEnd 1 restOfMsg
       in case TextParsec.parseOnly ((ins <|> del <|> upd <|> merge <|> sel <|> move <|> fetch <|> copy) <* TextParsec.endOfInput) astext of
            Left _ -> Just $ CommandComplete 0
            Right n -> Just $ CommandComplete n
    _ -> Nothing
    where
      ins = TextParsec.string "INSERT 0 " >> TextParsec.decimal
      del = TextParsec.string "DELETE " >> TextParsec.decimal
      upd = TextParsec.string "UPDATE " >> TextParsec.decimal
      merge = TextParsec.string "MERGE " >> TextParsec.decimal
      sel = TextParsec.string "SELECT " >> TextParsec.decimal
      move = TextParsec.string "MOVE " >> TextParsec.decimal
      fetch = TextParsec.string "FETCH " >> TextParsec.decimal
      copy = TextParsec.string "COPY " >> TextParsec.decimal

instance ToPgMessage CancelRequest where
  toPgMessage (CancelRequest pid secret) =
    Builder.int32BE (fromIntegral $ 4 + 4 + 4 + BS.length secret) <> Builder.int32BE 80877102 <> Builder.int32BE pid <> Builder.byteString secret

instance ToPgMessage CopyData where
  toPgMessage (CopyData bs) =
    let len = Builder.builderLength bs
     in Builder.char7 'd' <> Builder.int32BE (4 + len) <> bs

instance ToPgMessage CopyFail where
  toPgMessage (CopyFail bs) =
    let cstr = nulTermCString bs
     in Builder.char7 'f' <> Builder.int32BE (4 + fromIntegral (length bs) + 1) <> cstr

instance ToPgMessage CopyDone where
  toPgMessage CopyDone =
    Builder.char7 'c' <> Builder.int32BE 4

instance ToPgMessage Describe where
  toPgMessage Describe =
    let unnamedPortal = nulTermCString ""
     in Builder.char7 'D' <> Builder.int32BE (4 + 1 + 1) <> Builder.char7 'P' <> unnamedPortal

instance ToPgMessage Execute where
  toPgMessage _ =
    let unnamedPortal = nulTermCString ""
        infiniteRowsToReturn :: Int32 = 0
        contents = unnamedPortal <> Builder.int32BE infiniteRowsToReturn
        contentsLen = fromIntegral $ LBS.length $ Builder.toLazyByteString contents
     in Builder.char7 'E' <> Builder.int32BE (4 + contentsLen) <> contents

instance ToPgMessage Flush where
  toPgMessage Flush =
    Builder.char7 'H' <> Builder.int32BE 4

instance FromPgMessage CopyInResponse where
  msgParser = PgMsgParser $ \c _ -> case c of
    'G' -> Just CopyInResponse
    _ -> Nothing

instance FromPgMessage DataRow where
  msgParser = PgMsgParser $ \c !restOfMsg -> case c of
    'D' -> Just $ DataRow {rowColumnData = LBS.toStrict $ LBS.drop 2 restOfMsg}
    _ -> Nothing

instance FromPgMessage NoData where
  msgParser = PgMsgParser $ \c _ -> case c of
    'n' -> Just NoData
    _ -> Nothing

instance FromPgMessage ParameterStatus where
  msgParser = PgMsgParser $ \c !restOfMsg -> case c of
    'S' -> case LazyParsec.parseOnly (((,) <$> nulTerminatedCStringParser <*> nulTerminatedCStringParser) <* Parsec.endOfInput) restOfMsg of
      Left _ -> error "Failed parsing ParameterStatus"
      Right (parameterName, parameterValue) -> Just $ ParameterStatus {..}
    _ -> Nothing

instance FromPgMessage ParseComplete where
  msgParser = PgMsgParser $ \c _ -> case c of
    '1' -> Just ParseComplete
    _ -> Nothing

instance ToPgMessage PasswordMessage where
  toPgMessage (PasswordMessage crypt username password) =
    let passwordBs = case crypt of
          AuthCleartextPassword -> nulTermCString password
          AuthMD5Password (LBS.toStrict -> salt) ->
            let passwordBytes = Builder.toStrictByteString (Builder.string7 password)
                usernameBytes = Builder.toStrictByteString (Builder.string7 username)
                innerHex = bytestringToHex (md5Hash (passwordBytes <> usernameBytes))
                outerHex = bytestringToHex (md5Hash (innerHex <> salt))
             in Builder.byteString "md5" <> Builder.byteString outerHex <> Builder.word8 0
          _ -> error "PasswordMessage method not implemented"
        msgLen = builderLength passwordBs + 4
     in Builder.char7 'p' <> Builder.int32BE msgLen <> passwordBs
    where
      md5Hash :: ByteString -> ByteString
      md5Hash = convert @(Crypto.Digest Crypto.MD5) . Crypto.hash

instance ToPgMessage SASLInitialResponse where
  toPgMessage (SASLInitialResponse mechanism clientFirstMsg) =
    let mechanismBs = Builder.byteString (encodeUtf8 mechanism) <> Builder.word8 0
        dataLen = fromIntegral (BS.length clientFirstMsg) :: Int32
        contents = mechanismBs <> Builder.int32BE dataLen <> Builder.byteString clientFirstMsg
        msgLen = builderLength contents + 4
     in Builder.char7 'p' <> Builder.int32BE msgLen <> contents

instance ToPgMessage SASLResponse where
  toPgMessage (SASLResponse responseData) =
    let contents = Builder.byteString responseData.clientFinalMessage
        msgLen = builderLength contents + 4
     in Builder.char7 'p' <> Builder.int32BE msgLen <> contents

instance ToPgMessage Query where
  toPgMessage (Query bs) =
    -- TODO: Do we check if bytestring's length is too long or just continue ignoring the possibility?
    Builder.char7 'Q' <> Builder.int32BE (5 + fromIntegral (BS.length bs)) <> Builder.byteString bs <> Builder.word8 0

instance ToPgMessage Sync where
  toPgMessage Sync =
    Builder.char7 'S' <> Builder.int32BE 4

instance ToPgMessage Terminate where
  toPgMessage Terminate =
    Builder.char7 'X' <> Builder.int32BE 4

nulTermCString :: String -> Builder
nulTermCString s = Builder.string7 s <> Builder.word8 0

bytestringToHex :: ByteString -> ByteString
bytestringToHex = BS.concatMap (\w -> let (hi, lo) = w `divMod` 16 in BS.pack [hexChar hi, hexChar lo])
  where
    hexChar n
      | n < 10 = n + 48
      | otherwise = n + 87

instance ToPgMessage Bind where
  toPgMessage Bind {..} =
    let unnamedDestPortal = nulTermCString ""
        preparedStmtName = nulTermCString $ fromMaybe "" preparedStmtHash
        numParamFmtCodesAllBinary :: Int16 = 1
        fmtCodesBinary :: Int16 = 1
        numQryParams :: Int16 = fromIntegral $ length paramsValuesInOrder
        paramsLenAndVals =
          Builder.lazyByteString $
            Builder.toLazyByteString $
              mconcat $
                map
                  Builder.binaryField
                  paramsValuesInOrder
        numResultColumnsFmtCodes :: Int16 = fromIntegral resultColumnFmts
        resultColumnsFmtCodes =
          mconcat $
            map
              (const $ Builder.int16BE 1)
              [1 .. resultColumnFmts]
        contents = unnamedDestPortal <> preparedStmtName <> Builder.int16BE numParamFmtCodesAllBinary <> Builder.int16BE fmtCodesBinary <> Builder.int16BE numQryParams <> paramsLenAndVals <> Builder.int16BE numResultColumnsFmtCodes <> resultColumnsFmtCodes
        contentsLen = fromIntegral $ LBS.length $ Builder.toLazyByteString contents
     in Builder.char7 'B' <> Builder.int32BE (4 + contentsLen) <> contents

instance ToPgMessage Parse where
  toPgMessage Parse {..} =
    let preparedStmtName = nulTermCString $ fromMaybe "" preparedStmtHash
        numParamsSpecified :: Int16 = fromIntegral $ length specifiedParameterTypes
        -- A 0 for an OID means "type unspecified", and is what we use when we don't know the type
        paramOids = mconcat $ map (Builder.int32BE . maybe 0 fromIntegral) specifiedParameterTypes
        contents = preparedStmtName <> Builder.byteString queryString <> Builder.word8 0 <> Builder.int16BE numParamsSpecified <> paramOids
        contentsLen = fromIntegral $ LBS.length $ Builder.toLazyByteString contents
     in Builder.char7 'P' <> Builder.int32BE (4 + contentsLen) <> contents

instance ToPgMessage StartupMessage where
  toPgMessage StartupMessage {..} =
    let protocolMajorVersion :: Int16 = 3
        protocolMinorVersion :: Int16 = 0
        userBS = nulTermCString "user" <> nulTermCString user
        databaseBS = nulTermCString "database" <> nulTermCString database
        optionsBS = nulTermCString "options" <> nulTermCString options
        contents = Builder.int16BE protocolMajorVersion <> Builder.int16BE protocolMinorVersion <> userBS <> databaseBS <> optionsBS <> Builder.word8 0
        contentsLen = fromIntegral $ LBS.length $ Builder.toLazyByteString contents
     in -- TODO: What protocol version do we announce? We're following the example from the docs for now
        -- TODO: Can we send things like client_encoding, DateStyle, et. al as options here?

        Builder.int32BE (4 + contentsLen) <> contents

instance FromPgMessage ReadyForQuery where
  msgParser = PgMsgParser $ \c restOfMsg -> case (c, restOfMsg) of
    ('Z', "I") -> Just $ ReadyForQuery TransIdle
    ('Z', "T") -> Just $ ReadyForQuery TransInTrans
    ('Z', "E") -> Just $ ReadyForQuery TransInError
    _ -> Nothing

instance FromPgMessage RowDescription where
  msgParser = PgMsgParser $ \c restOfMsg ->
    if c == 'T'
      then
        let (numColsBS, colContents) = LBS.splitAt 2 restOfMsg
            numCols = either error id $ Cereal.decodeLazy @Int16 numColsBS
            allColOidsParser :: Parsec.Parser [(Text, Oid)]
            allColOidsParser = replicateM (fromIntegral numCols) colParser
         in case LazyParsec.parseOnly (allColOidsParser <* Parsec.endOfInput) colContents of
              Left err -> error $ "Error parsing row's column types' OIDs: " ++ err
              Right v -> Just $ RowDescription v
      else Nothing

instance FromPgMessage ErrorResponse where
  msgParser = PgMsgParser $ \c restOfMsg ->
    if c /= 'E'
      then Nothing
      else
        if LBS.length restOfMsg == 0
          then Just $ ErrorResponse mempty
          else
            let errorFields = LBS.split 0 restOfMsg
                parseSingleErrorField ef = do
                  -- Maybe monad
                  (fieldErrorByte, fieldErrorBs) <- LBS.uncons ef
                  errDetail <- byteToErrorDetail fieldErrorByte
                  pure (errDetail, fieldErrorBs)
             in Just $ ErrorResponse $ Map.fromList $ mapMaybe parseSingleErrorField errorFields

instance FromPgMessage NoticeResponse where
  msgParser = PgMsgParser $ \c restOfMsg ->
    if c /= 'N'
      then Nothing
      else
        -- TODO NOTICEs are exactly like ErrorResponses, so we could use a single parser
        if LBS.length restOfMsg == 0
          then Just $ NoticeResponse mempty
          else
            let errorFields = LBS.split 0 restOfMsg
                parseSingleErrorField ef = do
                  -- Maybe monad
                  (fieldErrorByte, fieldErrorBs) <- LBS.uncons ef
                  errDetail <- byteToErrorDetail fieldErrorByte
                  pure (errDetail, fieldErrorBs)
             in Just $ NoticeResponse $ Map.fromList $ mapMaybe parseSingleErrorField errorFields

instance FromPgMessage NotificationResponse where
  msgParser = PgMsgParser $ \c restOfMsg ->
    if c /= 'A'
      then Nothing
      else
        let (notifierPidBs, channelNameAndPayload) = LBS.splitAt 4 restOfMsg
            notifierPid = either error id $ Cereal.decodeLazy @Int32 notifierPidBs
         in case LazyParsec.parseOnly
              ((NotificationResponse notifierPid <$> nulTerminatedCStringParser <*> nulTerminatedCStringParser) <* Parsec.endOfInput)
              channelNameAndPayload of
              Left _ -> Nothing
              Right notif -> Just notif

byteToErrorDetail :: Word8 -> Maybe ErrorDetail
byteToErrorDetail b = case w2c b of
  -- Taken from https://www.postgresql.org/docs/current/protocol-error-fields.html
  'S' -> Nothing -- This is localized severity which can make code locale-dependent, so we don't want it!
  'V' -> Just ErrorSeverity
  -- S

  --     Severity: the field contents are ERROR, FATAL, or PANIC (in an error message), or WARNING, NOTICE, DEBUG, INFO, or LOG (in a notice message), or a localized translation of one of these. Always present.
  -- V

  --     Severity: the field contents are ERROR, FATAL, or PANIC (in an error message), or WARNING, NOTICE, DEBUG, INFO, or LOG (in a notice message). This is identical to the S field except that the contents are never localized. This is present only in messages generated by PostgreSQL versions 9.6 and later.
  'C' -> Just ErrorCode
  -- C

  --     Code: the SQLSTATE code for the error (see Appendix A). Not localizable. Always present.
  'M' -> Just ErrorHumanReadableMsg
  -- M

  --     Message: the primary human-readable error message. This should be accurate but terse (typically one line). Always present.
  'D' -> Just ErrorDetail
  -- D

  --     Detail: an optional secondary error message carrying more detail about the problem. Might run to multiple lines.
  'H' -> Just ErrorHint
  -- H

  --     Hint: an optional suggestion what to do about the problem. This is intended to differ from Detail in that it offers advice (potentially inappropriate) rather than hard facts. Might run to multiple lines.
  'P' -> Just ErrorPosition
  -- P

  --     Position: the field value is a decimal ASCII integer, indicating an error cursor position as an index into the original query string. The first character has index 1, and positions are measured in characters not bytes.
  'p' -> Just ErrorInternalPosition
  -- p

  --     Internal position: this is defined the same as the P field, but it is used when the cursor position refers to an internally generated command rather than the one submitted by the client. The q field will always appear when this field appears.
  'q' -> Just ErrorInternalCommand
  -- q

  --     Internal query: the text of a failed internally-generated command. This could be, for example, an SQL query issued by a PL/pgSQL function.
  'W' -> Just ErrorContext
  -- W

  --     Where: an indication of the context in which the error occurred. Presently this includes a call stack traceback of active procedural language functions and internally-generated queries. The trace is one entry per line, most recent first.
  's' -> Just ErrorSchema
  -- s

  --     Schema name: if the error was associated with a specific database object, the name of the schema containing that object, if any.
  't' -> Just ErrorTable
  -- t

  --     Table name: if the error was associated with a specific table, the name of the table. (Refer to the schema name field for the name of the table's schema.)
  'c' -> Just ErrorColumn
  -- c

  --     Column name: if the error was associated with a specific table column, the name of the column. (Refer to the schema and table name fields to identify the table.)
  'd' -> Just ErrorType
  -- d

  --     Data type name: if the error was associated with a specific data type, the name of the data type. (Refer to the schema name field for the name of the data type's schema.)
  'n' -> Just ErrorConstraint
  -- n

  --     Constraint name: if the error was associated with a specific constraint, the name of the constraint. Refer to fields listed above for the associated table or domain. (For this purpose, indexes are treated as constraints, even if they weren't created with constraint syntax.)
  'F' -> Just ErrorSourceFile
  -- F

  --     File: the file name of the source-code location where the error was reported.
  'L' -> Just ErrorSourceLine
  -- L

  --     Line: the line number of the source-code location where the error was reported.
  'R' -> Just ErrorSourceRoutine
  -- R

  --     Routine: the name of the source-code routine reporting the error.
  _ -> Nothing