packages feed

snappy-c-0.1.0: src/Codec/Compression/SnappyC/Internal/FrameFormat.hs

-- | Intended for unqualified import:
--
-- > import module Codec.Compression.SnappyC.Internal.FrameFormat

module Codec.Compression.SnappyC.Internal.FrameFormat
  ( -- * The 'Frame' type
    Frame(..)
  , FrameIdentifier(..)

    -- ** Encoding
  , Encoder(..)
  , EncodeParams(..)
  , FrameSize -- Opaque
  , Threshold(..)
  , EncodeState(..)
  , EncodeResult(..)
  , initializeEncoder
  , defaultEncodeParams
  , finalizeEncoder
  , encodeBuffered
  , customFrameSize
  , unFrameSize
  , defaultFrameSize

    -- ** Decoding
  , Decoder(..)
  , DecodeParams(..)
  , DecodeState(..)
  , DecodeResult(..)
  , DecodeFailure(..)
  , initializeDecoder
  , defaultDecodeParams
  , finalizeDecoder
  , decodeBuffered
  ) where

import Codec.Compression.SnappyC.Internal.Buffer (Buffer)
import Codec.Compression.SnappyC.Internal.Buffer qualified as Buffer
import Codec.Compression.SnappyC.Internal.Checksum (Checksum)
import Codec.Compression.SnappyC.Internal.Checksum qualified as Checksum
import Codec.Compression.SnappyC.Raw qualified as Raw

import Control.Exception
import Control.Monad.Error.Class
import Data.Bits
import Data.ByteString qualified as Strict (ByteString)
import Data.ByteString qualified as BS.Strict
import Data.Default
import Data.Word
import Text.Printf
import Control.Monad

-- | Snappy frames consist of a header and a payload.
data Frame =
      Frame
        { frameHeader :: !FrameHeader
        , framePayload :: !Strict.ByteString
        }
  deriving Show

-- | A frame's header contains an identifier corresponding to the frame type and
-- the size of the payload.
data FrameHeader =
      FrameHeader
        { frameHeaderIdentifier :: !FrameIdentifier
        , frameHeaderPayloadSize :: !Int
        }
  deriving (Show, Eq)

-- | Snappy frame identifiers.
data FrameIdentifier =
      StreamId
    | Compressed
    | Uncompressed
    | Padding
    | ReservedUnskippable Word8
    | ReservedSkippable Word8
  deriving (Show, Eq)

-- | The one byte value corresponding to an identifier.
encodeFrameIdentifier :: FrameIdentifier -> Word8
encodeFrameIdentifier StreamId                  = 0xff
encodeFrameIdentifier Compressed                = 0x00
encodeFrameIdentifier Uncompressed              = 0x01
encodeFrameIdentifier Padding                   = 0xfd
encodeFrameIdentifier (ReservedUnskippable fid) = fid
encodeFrameIdentifier (ReservedSkippable fid)   = fid

-- | Encode an 'Int' as a three byte little-endian value.
encodeFrameHeader :: FrameHeader -> Strict.ByteString
encodeFrameHeader (FrameHeader ident size)=
    BS.Strict.pack
      [ encodeFrameIdentifier ident
      , fromIntegral   (w32Size .&. 0x000000ff)
      , fromIntegral $ (w32Size .&. 0x0000ff00) `shiftR` 8
      , fromIntegral $ (w32Size .&. 0x00ff0000) `shiftR` 16
      ]
  where
    w32Size :: Word32
    w32Size = fromIntegral size

-------------------------------------------------------------------------------
-- Encoding Snappy frames
-------------------------------------------------------------------------------

-- | Buffers uncompressed data for compression.
newtype Encoder = Encoder { encoderBuffer :: Buffer }
  deriving Show

-- | Determines how much data is put in each Snappy frame and whether it is
-- compressed.
data EncodeParams =
      EncodeParams
        { -- | Exact amount of uncompressed data included in a single frame.
          frameSize :: !FrameSize

          -- | Compression threshold.
        , threshold :: !Threshold
        }
  deriving (Show, Eq)

instance Default EncodeParams where
  def :: EncodeParams
  def = defaultEncodeParams

defaultEncodeParams :: EncodeParams
defaultEncodeParams = EncodeParams def def

-- | Number of bytes of uncompressed data.
newtype FrameSize = FrameSize Int
  deriving (Show, Eq)

instance Default FrameSize where
    def :: FrameSize
    def = defaultFrameSize

-- | The default frame size is 65536 bytes, which is the maximum allowed by the
-- [Snappy framing format
-- description](https://github.com/google/snappy/blob/main/framing_format.txt).
defaultFrameSize :: FrameSize
defaultFrameSize = FrameSize snappySpecMaxChunkBytes

-- | See section 4.2 of the [Snappy framing format
-- description](https://github.com/google/snappy/blob/main/framing_format.txt).
snappySpecMaxChunkBytes :: Int
snappySpecMaxChunkBytes = 65536

-- | Create a 'FrameSize'.
--
-- Must be within the inclusive range [ 1 .. 65536 ].
customFrameSize :: Int -> FrameSize
customFrameSize n
    | n >= 1 && n <= snappySpecMaxChunkBytes
    = FrameSize n
    | otherwise
    = error "customFrameSize: invalid frame size"

-- | Unwrap a 'FrameSize'
unFrameSize :: FrameSize -> Int
unFrameSize (FrameSize n) = n

-- | Compression threshold, with explicit 'AlwaysCompress' and 'NeverCompress'
-- settings.
data Threshold =
      -- | Compress everything
      AlwaysCompress

      -- | Compress nothing
    | NeverCompress

      -- | Uncompressed size divided by compressed size.
      --
      -- Only produce compressed frames if the compression ratio for the data is
      -- equal to or above this threshold.
      --
      -- A higher threshold may result in less frames holding compressed data,
      -- and thus faster decompression/decoding.
      --
      -- [According to
      -- Google](https://github.com/google/snappy?tab=readme-ov-file#performance),
      -- the typical highest compression ratio that Snappy achieves is about 4,
      -- so a 'Ratio' of > 4.0 should be similar to 'NeverCompress', while a
      -- 'Ratio' of < 7/8 should be similar to 'AlwaysCompress'.
    | Ratio !Double
  deriving (Show, Eq)

instance Default Threshold where
  def :: Threshold
  def = defaultThreshold

-- | The default threshold is a ratio of 8:7, which was taken from the
-- [golang/snappy
-- implementation](https://github.com/golang/snappy/blob/43d5d4cd4e0e3390b0b645d5c3ef1187642403d8/encode.go#L231).
defaultThreshold :: Threshold
defaultThreshold = Ratio (1 / 0.875)

-- | Determines how much uncompressed data is stored in each resulting frame.
newtype EncodeState =
      EncodeState { encodeStateMaxChunkBytes :: Int }
  deriving Show

-- | A pair of frame-encoded chunks and an updated 'Encoder'.
data EncodeResult =
      EncodeResult
        { encodeResultEncoded :: [Strict.ByteString]
        , encodeResultEncoder :: !Encoder
        }
  deriving Show

-- | Initialize an 'Encoder' with the given maximum number of bytes of
-- uncompressed data to include in frames resulting from the 'Encoder'. If the
-- given number of bytes is not in the inclusive range [1 .. 65536], 65536 is
-- used.
--
-- The 'Strict.ByteString' holds the Snappy stream identifier frame that must be
-- included at the start of every Snappy frame encoded stream.
initializeEncoder :: (Strict.ByteString, Encoder)
initializeEncoder =
    ( "\xff\x06\x00\00sNaPpY"
    , Encoder
        { encoderBuffer = Buffer.empty
        }
    )

-- | Call to indicate no more input and flush the remaining data in the
-- 'Encoder' into a new frame.
--
-- If there is no more data in the 'Encoder', an empty list is returned.
--
-- * __Precondition:__ The buffer does not hold more data than the 'frameSize'
--   in the 'EncodeParams'. Use the postcondition of 'encodeBuffered' to ensure
--   this.
finalizeEncoder :: EncodeParams -> Encoder -> [Strict.ByteString]
finalizeEncoder ep (Encoder b)
    | Buffer.null b
    = []
    | otherwise
    = encodeChunk ep (Buffer.toStrict b)

-- | Fill and compress/encode as many frames as possible with the data in the
-- 'Encoder'.
--
-- /O(1)/ if there are not enough bytes in the buffer to fill a frame.
--
-- * __Postcondition:__ The resulting buffer never holds more than the
--   'frameSize' given in the 'EncodeParams'.
encodeBuffered :: EncodeParams -> Encoder -> EncodeResult
encodeBuffered ep@EncodeParams{..} = \(Encoder b) ->
    go [] b
  where
    go :: [Strict.ByteString] -> Buffer -> EncodeResult
    go acc b =
        case Buffer.splitExactly (unFrameSize frameSize) b of
          Right (chunk, b') ->
            go (reverse (encodeChunk ep chunk) ++ acc) b'
          Left _ ->
            EncodeResult
              (reverse acc)
              (Encoder b)

-- | Encode the input as a potentially compressed Snappy frame.
--
-- This function takes a 'Strict.ByteString' because it must pass all of the
-- data to a C function which expects the data to sit in a single buffer.
encodeChunk ::
     EncodeParams
  -> Strict.ByteString
  -> [Strict.ByteString]
encodeChunk EncodeParams{..} uncompressed =
    [ encodeFrameHeader
        (FrameHeader frameId (BS.Strict.length payloadData + 4))
    , Checksum.encode maskedChecksum
    , payloadData
    ]
  where
    maskedChecksum :: Checksum
    maskedChecksum = Checksum.calculate uncompressed

    compressed :: Strict.ByteString
    compressed = Raw.compress uncompressed

    compressionRatio :: Double
    compressionRatio =
        (/) @Double
          (fromIntegral $ BS.Strict.length uncompressed)
          (fromIntegral $ BS.Strict.length compressed)

    (frameId, payloadData) =
        if doCompress then
          (Compressed, compressed)
        else
          (Uncompressed, uncompressed)

    doCompress =
        case threshold of
          AlwaysCompress -> True
          NeverCompress  -> False
          Ratio ratio    -> compressionRatio >= ratio

-------------------------------------------------------------------------------
-- Decoding Snappy frames
-------------------------------------------------------------------------------

-- | Buffers compressed data for decompression and holds some useful
-- decompression state.
data Decoder =
      Decoder
        { -- | Accumulated Snappy framed data.
          --
          -- * __Invariant:__ This buffer never holds a fully decodable Snappy
          --   frame.
          decoderBuffer :: !Buffer

          -- | Tracks partial information about the buffer, e.g. whether we have
          -- decoded a header and how many bytes we need to fully decode a frame.
        , decoderState :: !DecodeState
        }
  deriving Show

-- | Have we decoded a header for the current frame yet?
--
-- If so, what was that header?
data DecodeState =
      Initial
    | KnownHeader !FrameHeader
  deriving (Show, Eq)

-- | Pair of decompressed data chunks and an updated 'Decoder'.
data DecodeResult =
      DecodeResult
        { decodeResultDecoded :: [Strict.ByteString]
        , decodeResultDecoder :: !Decoder
        }
  deriving Show


-- | Decode parameters
data DecodeParams =
      DecodeParams
        { -- | Verify the uncompressed data checksums during decompression
          --
          -- Defaults to 'False'. Even if we don't verify the CRC, if the data
          -- is not Snappy compressed then decompression will likely still fail
          -- due to failing to decode the frame headers.
          --
          -- To enable this, use the incremental API
          -- ('Codec.Compression.SnappyC.Framed.decompressStep'). Note that
          -- checksum verification adds a significant overhead to decompression.
          verifyChecksum :: !Bool
        }
  deriving (Show, Eq)

instance Default DecodeParams where
  def :: DecodeParams
  def = defaultDecodeParams

-- | Default decode parameters
defaultDecodeParams :: DecodeParams
defaultDecodeParams = DecodeParams False

-- | Possible failure modes for decompression.
data DecodeFailure =
      DecompressionError Strict.ByteString
    | ReservedUnskippableFrameId Word8
    | BadStreamId Strict.ByteString
    | BadChecksum
        Strict.ByteString -- ^ Data
        Checksum -- ^ Received
        Checksum -- ^ Computed
    | NotDone
  deriving Show
  deriving anyclass Exception

-- | The empty 'Decoder', in an initial state.
initializeDecoder :: Decoder
initializeDecoder =
    Decoder
      { decoderBuffer = Buffer.empty
      , decoderState = Initial
      }

-- | Verify that the 'Decoder' is complete.
--
-- If the 'Decoder'\'s buffer still has data in it, 'NotDone' is returned.
finalizeDecoder :: Decoder -> Either DecodeFailure ()
finalizeDecoder Decoder{..}
    | decoderState /= Initial || not (Buffer.null decoderBuffer)
    = throwError NotDone
    | otherwise
    = return ()

-- | Decompress/decode as many frames as possible with the data in the
-- 'Decoder'.
--
-- This is not as lazy as it could be. If we had a version of 'decodeFrame' that
-- threw exceptions on failure, we could be a bit more lazy. It's not clear to
-- me if this would actually be good for performance.
--
-- /O(1)/ if there are insufficient bytes in the buffer.
decodeBuffered :: DecodeParams -> Decoder -> Either DecodeFailure DecodeResult
decodeBuffered dps =
    go []
  where
    go :: [Strict.ByteString] -> Decoder -> Either DecodeFailure DecodeResult
    go acc (Decoder b state@Initial) =
        case Buffer.splitExactly 4 b of
          Right (headerBs, rest) -> do
            header <- decodeHeader headerBs
            go acc (Decoder rest (KnownHeader header))
          Left _ ->
            return $ DecodeResult (reverse acc) $ Decoder b state
    go acc (Decoder b state@(KnownHeader header)) =
        case Buffer.splitExactly (frameHeaderPayloadSize header) b of
          Right (payloadBs, rest) -> do
            uncompressed <- decodeFrame dps header payloadBs
            go (maybe acc (: acc) uncompressed) (Decoder rest Initial)
          Left _ ->
            return $ DecodeResult (reverse acc) $ Decoder b state


-- | Decode header
--
-- __Precondition:__ The given 'Strict.ByteString' must be exactly 4 bytes
-- long.
decodeHeader :: Strict.ByteString -> Either DecodeFailure FrameHeader
decodeHeader bs =
    case BS.Strict.unpack bs of
      [bid, b1, b2, b3] ->
        let
          payloadLen = lEWord24BytesToInt (b1, b2, b3)
        in
          case bid of
            0xff -> return $ FrameHeader StreamId     payloadLen
            0xfd -> return $ FrameHeader Padding      payloadLen
            0x00 -> return $ FrameHeader Compressed   payloadLen
            0x01 -> return $ FrameHeader Uncompressed payloadLen
            fid
              | fid `elem` [ 0x02 .. 0x7f ] ->
                  return $ FrameHeader (ReservedUnskippable fid) payloadLen
              | fid `elem` [ 0x80 .. 0xfd ] ->
                  return $ FrameHeader (ReservedSkippable fid) payloadLen
              | otherwise ->
                  error $
                    printf
                      ( "FrameFormat.decodeHeader: " ++
                        "impossible frame identifier 0x%x"
                      )
                      fid
      _ ->
        error "FrameFormat.decodeHeader: precondition violated"
  where
    lEWord24BytesToInt :: (Word8, Word8, Word8) -> Int
    lEWord24BytesToInt (lsb, mid, msb) =
            fromIntegral msb `shiftL` 16
        .|. fromIntegral mid `shiftL` 8
        .|. fromIntegral lsb

-- | Decode a frame
--
-- __Precondition:__ The given 'Strict.ByteString' is the payload associated
-- with the given 'FrameHeader'.
decodeFrame ::
     DecodeParams
  -> FrameHeader
  -> Strict.ByteString
  -> Either DecodeFailure (Maybe Strict.ByteString)
decodeFrame dps header bs =
    case frameHeaderIdentifier header of
      StreamId ->
        if bs == "sNaPpY" then
          return Nothing
        else
          throwError $ BadStreamId bs
      Compressed -> do
        let !(checksumBs, rest) = BS.Strict.splitAt 4 bs
        uncompressed <-
          case Raw.decompress rest of
            Nothing -> throwError $ DecompressionError rest
            Just decompressed -> return decompressed
        Just <$> verifyPayload dps checksumBs uncompressed
      Uncompressed -> do
        let !(checksumBs, uncompressed) = BS.Strict.splitAt 4 bs
        Just <$> verifyPayload dps checksumBs uncompressed
      Padding ->
        return Nothing
      ReservedUnskippable fid ->
        -- An unskippable reserved frame is one that has an important payload,
        -- but we don't know what it is so we can't decode it.
        throwError $ ReservedUnskippableFrameId fid
      ReservedSkippable _ ->
        return Nothing

-- | If checksum verification is enabled, compute the checksum and compare
-- against the decoded checksum.
verifyPayload ::
     DecodeParams
  -> Strict.ByteString
  -- ^ Encoded little-endian checksum
  -> Strict.ByteString
  -- ^ Uncompressed payload
  -> Either DecodeFailure Strict.ByteString
verifyPayload dps checksumBs uncompressed = do
    when (verifyChecksum dps) $ do
      let
        decodedChecksum = Checksum.decode checksumBs
        computedChecksum = Checksum.calculate uncompressed
      when (computedChecksum /= decodedChecksum) $
        throwError $
          BadChecksum uncompressed decodedChecksum computedChecksum
    return uncompressed