wireform-proto-0.2.0.0: src/Proto/Decode/Collect.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ScopedTypeVariables #-}
{- | Error-/accumulating/ message decoding.
The primary 'Proto.Decode.decodeMessage' is fail-fast: it stops at the first
'DecodeError'. This module adds a diagnostic, schema-driven pass that, like
the validation library, collects /all/ recoverable problems in a message at
once — each with a field path — instead of surfacing only the first.
'decodeCollecting' returns the list of 'DecodeIssue's plus the strictly
decoded value when (and only when) the message is clean. It is built on the
message's 'Proto.Schema.ProtoMessage' field descriptors and a self-contained
wire scanner, so it never touches the hot-path decoder.
What it reports:
* invalid UTF-8 in @string@ fields (each occurrence, with path/index);
* structurally malformed (e.g. truncated) sub-messages and map entries;
* malformed top-level wire data (bad varint, truncation, bad wire type).
Deep semantic checks inside sub-messages require the nested type's schema;
this pass validates each sub-message's framing one level down. Wire-type
mismatches on scalar fields are intentionally not flagged because packed
repeated fields make them ambiguous.
-}
module Proto.Decode.Collect (
DecodeIssue (..),
decodeCollecting,
collectIssues,
) where
import Control.DeepSeq (NFData)
import Data.Bits (shiftL, shiftR, (.&.), (.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Map.Strict qualified as Map
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Word (Word32, Word64)
import GHC.Generics (Generic)
import Proto.Decode (DecodeError (..), MessageDecode, decodeMessage)
import Proto.Schema (
FieldDescriptor (..),
FieldLabel' (..),
FieldTypeDescriptor (..),
ProtoMessage (..),
ScalarFieldType (..),
SomeFieldDescriptor (..),
)
{- | A single problem found while decoding, with a dotted/indexed field path
(empty for message-level / framing problems).
-}
data DecodeIssue = DecodeIssue
{ issuePath :: ![Text]
, issueError :: !DecodeError
}
deriving stock (Eq, Show, Generic)
instance NFData DecodeIssue
{- | Decode a message, collecting every recoverable problem. The typed value is
@'Just' a@ exactly when the strict decoder also succeeds (i.e. the message is
clean); otherwise it is 'Nothing' and 'fst' enumerates the problems.
-}
decodeCollecting :: forall a. (ProtoMessage a, MessageDecode a) => ByteString -> ([DecodeIssue], Maybe a)
decodeCollecting bytes =
let issues = collectIssues (Proxy :: Proxy a) bytes
in case decodeMessage bytes :: Either DecodeError a of
Right a -> (issues, Just a)
Left e -> (if null issues then [DecodeIssue [] e] else issues, Nothing)
-- | Collect issues for a message type's wire bytes using its field schema.
collectIssues :: ProtoMessage a => Proxy a -> ByteString -> [DecodeIssue]
collectIssues proxy bytes =
let fds = protoFieldDescriptors proxy
(entries, mErr) = scan bytes
fieldIssues = checkEntries fds entries
tailIssue = maybe [] (\e -> [DecodeIssue [] e]) mErr
in fieldIssues ++ tailIssue
-- Assign per-field occurrence indices, then check each entry against its
-- descriptor.
checkEntries :: Map.Map Int (SomeFieldDescriptor a) -> [(Int, WV)] -> [DecodeIssue]
checkEntries fds = go Map.empty
where
go _ [] = []
go counts ((fn, wv) : rest) =
let idx = Map.findWithDefault 0 fn counts
counts' = Map.insert fn (idx + 1) counts
in checkOne fds fn idx wv ++ go counts' rest
checkOne :: Map.Map Int (SomeFieldDescriptor a) -> Int -> Int -> WV -> [DecodeIssue]
checkOne fds fn idx wv = case Map.lookup fn fds of
Nothing -> [] -- unknown field: allowed, preserved elsewhere
Just (SomeField fd) ->
let seg =
if fdLabel fd == LabelRepeated
then fdName fd <> "[" <> T.pack (show idx) <> "]"
else fdName fd
issue e = [DecodeIssue [seg] e]
in case fdTypeDesc fd of
ScalarType StringField -> case wv of
WLen bs -> case TE.decodeUtf8' bs of
Left _ -> issue InvalidUtf8
Right _ -> []
_ -> []
MessageType _ -> subMessageIssues issue wv
MapType _ _ -> subMessageIssues issue wv
_ -> []
subMessageIssues :: (DecodeError -> [DecodeIssue]) -> WV -> [DecodeIssue]
subMessageIssues issue wv = case wv of
WLen bs -> case snd (scan bs) of
Just e -> issue (SubMessageError e)
Nothing -> []
_ -> []
----------------------------------------------------------------------
-- Self-contained wire scanner
----------------------------------------------------------------------
data WV = WVarint !Word64 | WI64 !Word64 | WI32 !Word32 | WLen !ByteString
{- | Scan a message into @(fieldNumber, value)@ pairs, preserving repeats.
Returns any trailing structural error encountered.
-}
scan :: ByteString -> ([(Int, WV)], Maybe DecodeError)
scan = go id
where
go acc bs
| BS.null bs = (acc [], Nothing)
| otherwise = case readVarint bs of
Nothing -> (acc [], Just InvalidVarint)
Just (tag, r) ->
let fn = fromIntegral (tag `shiftR` 3)
wt = tag .&. 7
in case wt of
0 -> case readVarint r of
Just (v, r2) -> go (acc . ((fn, WVarint v) :)) r2
Nothing -> (acc [], Just InvalidVarint)
1 ->
if BS.length r >= 8
then go (acc . ((fn, WI64 (le64 (BS.take 8 r))) :)) (BS.drop 8 r)
else (acc [], Just UnexpectedEnd)
5 ->
if BS.length r >= 4
then go (acc . ((fn, WI32 (le32 (BS.take 4 r))) :)) (BS.drop 4 r)
else (acc [], Just UnexpectedEnd)
2 -> case readVarint r of
Just (len, r2) ->
let n = fromIntegral len
in if n >= 0 && BS.length r2 >= n
then go (acc . ((fn, WLen (BS.take n r2)) :)) (BS.drop n r2)
else (acc [], Just UnexpectedEnd)
Nothing -> (acc [], Just InvalidVarint)
_ -> (acc [], Just (InvalidWireType (fromIntegral wt)))
readVarint :: ByteString -> Maybe (Word64, ByteString)
readVarint = goV 0 0
where
goV !shift !acc bs = case BS.uncons bs of
Nothing -> Nothing
Just (b, rest) ->
let acc' = acc .|. (fromIntegral (b .&. 0x7F) `shiftL` shift)
in if b .&. 0x80 /= 0
then if shift >= 63 then Nothing else goV (shift + 7) acc' rest
else Just (acc', rest)
le64 :: ByteString -> Word64
le64 = BS.foldr (\b a -> a `shiftL` 8 .|. fromIntegral b) 0
le32 :: ByteString -> Word32
le32 = BS.foldr (\b a -> a `shiftL` 8 .|. fromIntegral b) 0