grpc-spec-1.0.0: src/Network/GRPC/Spec/PercentEncoding.hs
-- | Percent encoding
--
-- The gRPC spec is similar, but not identical, to URI encoding.
--
-- > Percent-Encoded → 1*(Percent-Byte-Unencoded / Percent-Byte-Encoded)
-- > Percent-Byte-Unencoded → 1*( %x20-%x24 / %x26-%x7E ) ; space and VCHAR, except %
-- > Percent-Byte-Encoded → "%" 2HEXDIGIT ; 0-9 A-F
--
-- We work with strict bytestrings here, since these are ultimately intended
-- as header values.
--
-- Intended for qualified import.
--
-- > import Network.GRPC.Spec.PercentEncoding qualified as PercentEncoding
module Network.GRPC.Spec.PercentEncoding (
encode
, decode
) where
import Control.Exception
import Data.Bifunctor
import Data.Bits
import Data.ByteString qualified as BS.Strict
import Data.ByteString qualified as Strict (ByteString)
import Data.ByteString.Builder (Builder)
import Data.ByteString.Builder qualified as Builder
import Data.ByteString.Lazy qualified as BS.Lazy
import Data.Text (Text)
import Data.Text.Encoding qualified as Text
import Data.Text.Encoding.Error qualified as Text
import Data.Word
import Network.GRPC.Spec.Util.ByteString (ascii)
{-------------------------------------------------------------------------------
Encoding
-------------------------------------------------------------------------------}
encode :: Text -> Strict.ByteString
encode =
BS.Lazy.toStrict
. Builder.toLazyByteString
. foldMap encodeChar
. BS.Strict.unpack
. Text.encodeUtf8
encodeChar :: Word8 -> Builder
encodeChar c
| needsEncoding c = let (hi, lo) = toBase16 c
in mconcat [
Builder.char7 '%'
, Builder.word8 hi
, Builder.word8 lo
]
| otherwise = Builder.word8 c
-- | Does this character have to be encoded?
--
-- The gRPC spec unencoded characters as "space and VCHAR, except %":
--
-- > Percent-Byte-Unencoded → 1*( %x20-%x24 / %x26-%x7E )
needsEncoding :: Word8 -> Bool
needsEncoding c
| 0x20 <= c && c <= 0x24 = False
| 0x26 <= c && c <= 0x7E = False
| otherwise = True
{-------------------------------------------------------------------------------
Decoding
-------------------------------------------------------------------------------}
data DecodeException =
-- | Hex digit outside its range @(0..9, A..F)@
InvalidHexDigit Word8
-- | Percent (@%@) which was not followed by two hex digits
| MissingHexDigits
-- | Hex-decoding was fine, but encoded string was not valid UTF8
| InvalidUtf8 Text.UnicodeException
deriving stock (Show)
instance Exception DecodeException where
displayException (InvalidHexDigit w) =
"invalid hex digit '" ++ show w ++ "'"
displayException MissingHexDigits =
"'%' not followed by two hex digits"
displayException (InvalidUtf8 err) =
displayException err
decode :: Strict.ByteString -> Either DecodeException Text
decode =
(>>= first InvalidUtf8 . Text.decodeUtf8')
. fmap BS.Strict.pack
. go []
. BS.Strict.unpack
where
go :: [Word8] -> [Word8] -> Either DecodeException [Word8]
go acc (c:cs)
| c == ascii '%' = case cs of
hi:lo:cs' -> do
c' <- fromBase16 (hi, lo)
go (c':acc) cs'
_otherwise ->
Left $ MissingHexDigits
| otherwise = go (c:acc) cs
go acc [] = Right (reverse acc)
{-------------------------------------------------------------------------------
Utilities for working with base16
We could depend on @base16@ or @base16-bytestring@ here, but they deal with
entire strings at a time, which doesn't quite fit our needs here and would
result in quite a bit of overhead.
-------------------------------------------------------------------------------}
toBase16 :: Word8 -> (Word8, Word8)
toBase16 c = (toHexDigit hi, toHexDigit lo)
where
hi, lo :: Word8
hi = c `shiftR` 4
lo = c .&. 0x0F;
fromBase16 :: (Word8, Word8) -> Either DecodeException Word8
fromBase16 = \(hi, lo) -> aux <$> fromHexDigit hi <*> fromHexDigit lo
where
aux :: Word8 -> Word8 -> Word8
aux hi lo = (hi `shiftL` 4) .|. lo
toHexDigit :: Word8 -> Word8
toHexDigit c
| 0 <= c && c <= 9 = ascii '0' + c
| 10 <= c && c <= 15 = ascii 'A' + (c - 10)
| otherwise = error "toHexDigit: out of range"
-- | Value of a single hex digit
--
-- The gRPC spec does not actually allow for lowercase here, but we support it
-- in case we're dealing with non-conformant peers.
fromHexDigit :: Word8 -> Either DecodeException Word8
fromHexDigit c
| ascii '0' <= c && c <= ascii '9' = Right $ c - ascii '0'
| ascii 'A' <= c && c <= ascii 'F' = Right $ 10 + c - ascii 'A'
| ascii 'a' <= c && c <= ascii 'f' = Right $ 10 + c - ascii 'a'
| otherwise = Left $ InvalidHexDigit c