mariadb-haskell-0.1.0.0: src/Database/MySQL/Protocol/Compression.hs
{-# LANGUAGE BangPatterns #-}
-- | Optional MariaDB zlib framing, below ordinary packet framing and above the
-- raw TCP/TLS byte transport. Compressed frames may contain several packets or
-- just part of one. Ordinary exchanges share a modulo-256 sequence; semisync
-- replication needs a separate policy because its two directions interleave.
module Database.MySQL.Protocol.Compression
( CompressionTransport(..), CompressionException(..), newCompressionTransport ) where
import Control.Exception (Exception, throwIO)
import Control.Monad (unless, when)
import qualified Codec.Compression.Zlib as Zlib
import qualified Codec.Compression.Zlib.Internal as Z
import Data.Binary.Put
import Data.Bits
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as L
import Data.IORef
import Data.Word
import qualified System.IO.Streams as S
-- | Invalid compressed framing or expansion. The enclosing connection retires
-- its transport; decompression errors never become a clean end-of-stream.
data CompressionException = CompressionException String deriving Show
instance Exception CompressionException
-- | Start only after successful plugin authentication. Reset once before each
-- ordinary command, never between its continuation packets or reply packets.
data CompressionTransport = CompressionTransport
{ compressionInput :: !(S.InputStream B.ByteString)
, compressionSend :: !(L.ByteString -> IO ())
, compressionStart :: !(IO ())
, compressionReset :: !(IO ())
, compressionAllowReplicationResets :: !(IO ())
}
-- | Construct a disabled transport. Inbound compressed and expanded frames are
-- independently bounded by the smaller of the supplied byte limit and 16 MiB-1.
-- Outbound frames contain at most 64 KiB of ordinary wire bytes. Small or
-- incompressible frames use the protocol's uncompressed representation.
newCompressionTransport :: Word64 -> Int -> S.InputStream B.ByteString
-> (L.ByteString -> IO ()) -> IO CompressionTransport
newCompressionTransport limit threshold input output = do
enabled <- newIORef False
sequenceNumber <- newIORef (0 :: Word8)
replicationResets <- newIORef False
pending <- newIORef []
let bad = throwIO . CompressionException
ceilingBytes = fromIntegral (min 16777215 (min limit (fromIntegral (maxBound :: Int))))
advance actual = do
expected <- readIORef sequenceNumber
allowReset <- readIORef replicationResets
-- MariaDB's semisync sender resets after synchronizing flushes,
-- while ACKs travel independently in the reverse direction. As in
-- Connector/C's pkt_nr_can_be_reset policy, no shared expected
-- sequence exists in that dedicated stream. Ordinary commands and
-- replication without ACKs retain the strict check.
unless (allowReset || actual == expected) (bad "unexpected compressed packet sequence")
writeIORef sequenceNumber (actual + 1)
readFrame = do
header <- S.readExactly 7 input
let packed = word24 header 0
expanded = word24 header 4
when (packed == 0 || packed > ceilingBytes || expanded > ceilingBytes)
(bad "compressed frame exceeds client limit or is empty")
advance (B.index header 3)
bytes <- S.readExactly packed input
if expanded == 0 then pure [bytes]
else L.toChunks <$> inflateExact expanded bytes
readCompressed = do
chunks <- readIORef pending
case chunks of
first:rest -> writeIORef pending rest >> pure (Just first)
[] -> do
chunks' <- readFrame
case chunks' of
[] -> bad "compressed frame produced no bytes"
first:rest -> writeIORef pending rest >> pure (Just first)
sendFrames bytes
| L.null bytes = pure ()
| otherwise = do
let (part,rest) = L.splitAt 65536 bytes
count = fromIntegral (L.length part)
compressed = if count >= threshold then Zlib.compress part else part
savesBytes = count >= threshold && L.length compressed < fromIntegral count
payload = if savesBytes then compressed else part
expanded = if savesBytes then count else 0
sequenceN <- readIORef sequenceNumber
writeIORef sequenceNumber (sequenceN + 1)
output (runPut (put24 (fromIntegral (L.length payload)) >> putWord8 sequenceN >>
put24 expanded >> putLazyByteString payload))
sendFrames rest
source <- S.makeInputStream $ do
active <- readIORef enabled
if active then readCompressed else S.read input
let send bytes = readIORef enabled >>= \active -> if active then sendFrames bytes else output bytes
start = writeIORef sequenceNumber 0 >> writeIORef enabled True
reset = writeIORef sequenceNumber 0
allowReplicationResets = writeIORef replicationResets True
pure (CompressionTransport source send start reset allowReplicationResets)
where
word24 :: B.ByteString -> Int -> Int
word24 bytes offset = fromIntegral (B.index bytes offset) .|.
(fromIntegral (B.index bytes (offset+1)) `shiftL` 8) .|.
(fromIntegral (B.index bytes (offset+2)) `shiftL` 16)
put24 :: Int -> Put
put24 n = putWord8 (fromIntegral n) >> putWord8 (fromIntegral (n `shiftR` 8)) >>
putWord8 (fromIntegral (n `shiftR` 16))
-- Streaming zlib inspection bounds output before retaining it and checks both
-- the advertised length and compressed stream termination. The lazy pure
-- decompressor alone cannot distinguish a false length or trailing stream.
inflateExact :: Int -> B.ByteString -> IO L.ByteString
inflateExact expected input = go 0 [] [input] (Z.decompressIO Z.zlibFormat Z.defaultDecompressParams)
where
bad = throwIO . CompressionException
go !size chunks pending stream = case stream of
Z.DecompressInputRequired next -> case pending of
[] -> next B.empty >>= go size chunks []
first:rest -> next first >>= go size chunks rest
Z.DecompressOutputAvailable chunk next -> do
let total = size + B.length chunk
when (total > expected) (bad "zlib output exceeds advertised frame length")
next >>= go total (chunk:chunks) pending
Z.DecompressStreamEnd trailing -> do
unless (size == expected && B.null trailing && all B.null pending)
(bad "zlib frame length or trailing data mismatch")
pure (L.fromChunks (reverse chunks))
Z.DecompressStreamError err -> bad ("invalid zlib frame: " ++ show err)