packages feed

syslog-0.1.0.0: src/Syslog/Ietf.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE UnboxedTuples #-}

{- | Parse RFC 5424 messages. For example (from the spec itself):

> <165>1 2003-10-11T22:14:15.003Z mymachine.example.com
>   evntslog - ID47 [exampleSDID@32473 iut="3" eventSource="Application"
>   eventID="1011"] BOMAn application event log entry...
-}
module Syslog.Ietf
  ( -- * Types
    Message (..)
  , Element (..)
  , Parameter (..)

    -- * Full Decode
  , decode
  , parser
  ) where

import Prelude hiding (id)

import Control.Monad (when)
import Control.Monad.ST.Run (runIntByteArrayST)
import Data.Bytes.Parser (Parser)
import Data.Bytes.Types (Bytes (Bytes))
import Data.Int (Int64)
import Data.Primitive (SmallArray)
import Data.Word (Word32, Word64, Word8)

import qualified Chronos
import qualified Data.Bytes.Parser as Parser
import qualified Data.Bytes.Parser.Latin as Latin
import qualified Data.Bytes.Parser.Unsafe as Unsafe
import qualified Data.Bytes.Types
import qualified Data.Maybe.Unpacked.Numeric.Word32 as Word32
import qualified Data.Primitive as PM
import qualified Data.Primitive.Contiguous as C

data Message = Message
  { priority :: !Word32
  , version :: !Word32
  , timestamp :: !Chronos.OffsetDatetime
  , hostname :: {-# UNPACK #-} !Bytes
  , application :: {-# UNPACK #-} !Bytes
  , processId :: {-# UNPACK #-} !Word32.Maybe
  , messageType :: {-# UNPACK #-} !Bytes
  -- ^ A missing message type, represented as a hyphen in IETF-flavor
  -- syslog, is represented by the empty byte sequence.
  , structuredData :: {-# UNPACK #-} !(SmallArray Element)
  , message :: {-# UNPACK #-} !Bytes
  }
  deriving (Show)

data Element = Element
  { id :: {-# UNPACK #-} !Bytes
  , parameters :: {-# UNPACK #-} !(SmallArray Parameter)
  }
  deriving (Show)

data Parameter = Parameter
  { name :: {-# UNPACK #-} !Bytes
  , value :: {-# UNPACK #-} !Bytes
  }
  deriving (Show)

-- | Run the RFC 5424 parser. See 'parser'.
decode :: Bytes -> Maybe Message
decode = Parser.parseBytesMaybe parser

-- | Parse a RFC 5424 message.
parser :: Parser () s Message
parser = do
  priority <- takePriority ()
  version <- Latin.decWord32 ()
  Latin.char () ' '
  timestamp <- takeTimestamp
  Latin.char () ' '
  hostname <- takeKeywordAndSpace ()
  application <- takeKeywordAndSpace ()
  processId <-
    Latin.trySatisfy (== '-') >>= \case
      True -> do
        Latin.char () ' '
        pure Word32.nothing
      False ->
        Parser.orElse
          -- This is a hack to smooth over a mistake. The process id
          -- can actually be things other than a decimal-encoded number.
          -- Sometimes it is 128-bit or 256-bit hexadecimal number. In
          -- these cases, we just ignore it.
          ( do
              w <- Latin.decWord32 ()
              Latin.char () ' '
              pure (Word32.just w)
          )
          ( do
              Latin.skipWhile
                (\c -> (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || (c >= '0' && c <= '9'))
              Latin.char () ' '
              pure Word32.nothing
          )
  messageType <-
    Latin.trySatisfy (== '-') >>= \case
      True -> do
        Latin.char () ' '
        array <- Unsafe.expose
        pure Bytes {array, offset = 0, length = 0}
      False -> takeKeywordAndSpace ()
  structuredData <-
    Latin.trySatisfy (== '-') >>= \case
      True -> pure mempty
      False -> takeStructuredData
  message <-
    Parser.isEndOfInput >>= \case
      True -> do
        arr <- Unsafe.expose
        pure Bytes {array = arr, offset = 0, length = 0}
      False -> do
        Latin.char () ' '
        Parser.remaining
  pure
    Message
      { priority
      , version
      , timestamp
      , hostname
      , application
      , processId
      , messageType
      , structuredData
      , message
      }

takeStructuredData :: Parser () s (SmallArray Element)
takeStructuredData = go 0 []
 where
  go :: Int -> [Element] -> Parser () s (SmallArray Element)
  go !n !acc =
    Latin.trySatisfy (== '[') >>= \case
      True -> do
        id <- takeKeyword
        parameters <- takeParameters
        let !e = Element {id, parameters}
        go (n + 1) (e : acc)
      False -> pure $! C.unsafeFromListReverseN n acc

takeParameters :: Parser () s (SmallArray Parameter)
takeParameters = go 0 []
 where
  go :: Int -> [Parameter] -> Parser () s (SmallArray Parameter)
  go !n !acc =
    Latin.trySatisfy (== ']') >>= \case
      True -> pure $! C.unsafeFromListReverseN n acc
      False -> do
        Latin.char () ' '
        name <- takeKeywordAndEquals
        value <- takeParameterValue
        let !p = Parameter {name, value}
        go (n + 1) (p : acc)

-- This handles escape sequences correctly.
takeParameterValue :: Parser () s Bytes
takeParameterValue = do
  Latin.char () '"'
  start <- Unsafe.cursor
  Parser.skipTrailedBy2 () 0x22 0x5C >>= \case
    False -> do
      -- no backslashes, went all the way to a double quote
      end <- Unsafe.cursor
      let !len = (end - start) - 1
      arr <- Unsafe.expose
      pure Bytes {array = arr, offset = start, length = len}
    True -> do
      -- found a backslash, we will need to escape quotes
      c <- Latin.any ()
      if c == '"' || c == '\\'
        then pure ()
        else Parser.fail ()
      consumeThroughUnescapedQuote
      end <- Unsafe.cursor
      let !len = (end - start) - 1
      arr <- Unsafe.expose
      let bs = Bytes {array = arr, offset = start, length = len}
      pure $! removeEscapeSequences bs

consumeThroughUnescapedQuote :: Parser () s ()
consumeThroughUnescapedQuote =
  Parser.skipTrailedBy2 () 0x22 0x5C >>= \case
    False -> pure ()
    True -> do
      c <- Latin.any ()
      if c == '"' || c == '\\'
        then consumeThroughUnescapedQuote
        else Parser.fail ()

{- | Precondition: Every backslash is followed by a double quote or by
a backslash.
-}
removeEscapeSequences :: Bytes -> Bytes
removeEscapeSequences Bytes {array, offset = off0, length = len0} =
  let (lengthX, arrayX) = runIntByteArrayST $ do
        dst <- PM.newByteArray len0
        let go !ixSrc !ixDst !len = case len of
              0 -> pure ixDst
              _ -> do
                let w :: Word8 = PM.indexByteArray array ixSrc
                case w of
                  0x5C -> case PM.indexByteArray array (ixSrc + 1) :: Word8 of
                    0x5C -> do
                      PM.writeByteArray dst ixDst (0x5C :: Word8)
                      go (ixSrc + 2) (ixDst + 1) (len - 2)
                    0x22 -> do
                      PM.writeByteArray dst ixDst (0x22 :: Word8)
                      go (ixSrc + 2) (ixDst + 1) (len - 2)
                    _ -> errorWithoutStackTrace "Syslog.Ietf.removeEscapeSequences: invariant violated"
                  _ -> do
                    PM.writeByteArray dst ixDst w
                    go (ixSrc + 1) (ixDst + 1) (len - 1)
        lenDst <- go off0 0 len0
        PM.shrinkMutableByteArray dst lenDst
        dst' <- PM.unsafeFreezeByteArray dst
        pure (lenDst, dst')
   in Bytes {array = arrayX, length = lengthX, offset = 0}

{- | Consume the angle-bracketed priority. RFC 5424 does not allow
a space to follow the priority, so this does not consume a
trailing space.
-}
takePriority :: e -> Parser e s Word32
takePriority e = do
  Latin.char e '<'
  priority <- Latin.decWord32 e
  Latin.char e '>'
  pure priority

{- | Consume the keyword and the space that follows it. Returns
the hostname.
-}
takeKeywordAndSpace :: e -> Parser e s Bytes
takeKeywordAndSpace e =
  -- TODO: This should actually use a takeWhile1.
  Latin.takeTrailedBy e ' '

-- | Consume the keyword. Returns the keyword.
takeKeyword :: Parser e s Bytes
takeKeyword =
  -- TODO: Should use takeWhile1
  Parser.takeWhile (\c -> c /= 0x20)

{- | Consume the keyword and the equals sign that follows it. Returns
the keyword.
-}
takeKeywordAndEquals :: Parser () s Bytes
takeKeywordAndEquals =
  -- TODO: This should actually use a takeWhile1.
  Latin.takeTrailedBy () '='

-- | Consume the timestamp.
takeTimestamp :: Parser () s Chronos.OffsetDatetime
takeTimestamp = do
  year <- Latin.decWord ()
  Latin.char () '-'
  month' <- Latin.decWord ()
  let !month = month' - 1
  when (month >= 12) (Parser.fail ())
  Latin.char () '-'
  day <- Latin.decWord ()
  Latin.char () 'T'
  hour <- Latin.decWord ()
  Latin.char () ':'
  minute <- Latin.decWord ()
  Latin.char () ':'
  sec <- Latin.decWord ()
  let date =
        Chronos.Date
          (Chronos.Year (fromIntegral year))
          (Chronos.Month (fromIntegral month))
          (Chronos.DayOfMonth (fromIntegral day))
  !nanos <-
    Latin.trySatisfy (== '.') >>= \case
      True -> do
        (n, w) <- Parser.measure (Latin.decWord64 ())
        when (n > 9) (Parser.fail ())
        let go !acc !b = case b of
              0 -> acc
              _ -> go (acc * 10) (b - 1)
            !ns = go w (9 - n)
        pure ns
      False -> pure 0
  off <-
    Latin.any () >>= \case
      'Z' -> pure 0
      '+' -> parserOffset
      '-' -> do
        !off <- parserOffset
        pure (negate off)
      _ -> Parser.fail ()
  pure $!
    Chronos.OffsetDatetime
      ( Chronos.Datetime date $
          Chronos.TimeOfDay
            (fromIntegral hour)
            (fromIntegral minute)
            (fromIntegral @Word64 @Int64 (fromIntegral sec * 1000000000 + nanos))
      )
      (Chronos.Offset off)

-- Should consume exactly five characters: HH:MM. However, the implementation
-- is more generous.
parserOffset :: Parser () s Int
parserOffset = do
  h <- Latin.decWord8 ()
  Latin.char () ':'
  m <- Latin.decWord8 ()
  let !r = ((fromIntegral @Word8 @Int h) * 60) + fromIntegral @Word8 @Int m
  pure r