sd-jwt-0.1.0.0: test/interop/TestCaseParser.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE LambdaCase #-}
-- | Parser for Python SD-JWT test case YAML specifications.
--
-- This module parses the YAML test case files from the Python SD-JWT library
-- and converts them to Haskell test case structures.
module TestCaseParser
( TestCase(..)
, loadTestCase
, parseUserClaims
, parseHolderDisclosedClaims
, extractSelectivelyDisclosablePaths
) where
import Data.Aeson (Value(..), Object, (.=), (.:), (.:?), (.!=), object)
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import qualified Data.Vector as V
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.YAML (decodeNode, Node(..), docRoot, Scalar(..), Mapping, Pos)
import System.FilePath ((</>))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BSL
import Text.Read (readMaybe)
-- | Test case structure matching Python test case format
data TestCase = TestCase
{ tcUserClaims :: Map Text Value
-- ^ Input claims with selective disclosure markers
, tcHolderDisclosedClaims :: Map Text Value
-- ^ Which claims the holder should disclose (boolean flags or claim names)
, tcExpectedVerifiedClaims :: Map Text Value
-- ^ Expected verified claims after selective disclosure
, tcKeyBinding :: Bool
-- ^ Whether key binding is enabled
, tcSerializationFormat :: Maybe Text
-- ^ Serialization format: "compact" or "json"
, tcAddDecoyClaims :: Bool
-- ^ Whether to add decoy claims
, tcExtraHeaderParameters :: Map Text Value
-- ^ Extra header parameters
}
deriving (Eq, Show)
-- | Load a test case from a YAML specification file
--
-- Uses HsYAML which preserves YAML tags. We parse to Node first, then convert
-- to Aeson.Value by walking the structure (ignoring tags for Aeson conversion).
loadTestCase :: FilePath -> IO (Either String TestCase)
loadTestCase filePath = do
yamlContent <- BS.readFile filePath
-- Parse to Node, then convert to Aeson.Value
case decodeNode (BSL.fromStrict yamlContent) of
Right [doc] -> do
-- Convert Node to Aeson.Value by walking the structure
let aesonValue = nodeToAeson (docRoot doc)
return $ parseTestCase aesonValue
Right _ -> return $ Left "Expected single YAML document"
Left (pos, err) -> return $ Left $ "Failed to parse YAML at " ++ show pos ++ ": " ++ err
-- | Convert HsYAML Node to Aeson.Value (ignoring tags)
nodeToAeson :: Node Pos -> Aeson.Value
nodeToAeson node = case node of
Scalar _ scalar -> scalarToAeson scalar
Mapping _ _ pairs -> Aeson.Object $ KeyMap.fromList $ map (\(k, v) ->
(keyToKey k, nodeToAeson v)) (Map.toList pairs)
Sequence _ _ items -> Aeson.Array $ V.fromList $ map nodeToAeson items
Anchor _ _ innerNode -> nodeToAeson innerNode
scalarToAeson :: Scalar -> Aeson.Value
scalarToAeson scalar = case scalar of
SNull -> Aeson.Null
SBool b -> Aeson.Bool b
SInt i -> Aeson.Number (fromInteger i)
SFloat d -> Aeson.Number (realToFrac d)
SStr s -> Aeson.String s
SUnknown _ s ->
-- Try to parse the string as a number or boolean, otherwise treat as string
case parseScalarValue s of
Just (Aeson.Number n) -> Aeson.Number n
Just (Aeson.Bool b) -> Aeson.Bool b
Just Aeson.Null -> Aeson.Null
_ -> Aeson.String s
-- | Try to parse a string as a scalar value (number, boolean, null)
parseScalarValue :: T.Text -> Maybe Aeson.Value
parseScalarValue s
| s == "null" || s == "Null" || s == "NULL" = Just Aeson.Null
| s == "true" || s == "True" || s == "TRUE" = Just (Aeson.Bool True)
| s == "false" || s == "False" || s == "FALSE" = Just (Aeson.Bool False)
| Just i <- readMaybe (T.unpack s) :: Maybe Integer = Just (Aeson.Number (fromInteger i))
| Just d <- readMaybe (T.unpack s) :: Maybe Double = Just (Aeson.Number (realToFrac d))
| otherwise = Nothing
keyToKey :: Node Pos -> Key.Key
keyToKey node = case node of
Scalar _ (SStr s) -> Key.fromText s
Scalar _ (SUnknown _ s) -> Key.fromText s -- Extract value from SUnknown
Scalar _ (SInt i) -> Key.fromText (T.pack (show i))
Scalar _ (SFloat d) -> Key.fromText (T.pack (show d))
Scalar _ (SBool b) -> Key.fromText (T.pack (show b))
Scalar _ SNull -> Key.fromText "null"
_ -> Key.fromText (T.pack (show node))
-- | Extract text value from a scalar node (for path extraction)
extractScalarTextFromNode :: Node Pos -> T.Text
extractScalarTextFromNode node = case node of
Scalar _ (SStr s) -> s
Scalar _ (SUnknown _ s) -> s
Scalar _ (SInt i) -> T.pack (show i)
Scalar _ (SFloat d) -> T.pack (show d)
Scalar _ (SBool b) -> T.pack (show b)
Scalar _ SNull -> "null"
_ -> T.pack (show node)
-- | Parse an Aeson Value into a TestCase
parseTestCase :: Aeson.Value -> Either String TestCase
parseTestCase (Object obj) = do
userClaims <- parseField "user_claims" obj >>= parseUserClaims
holderDisclosed <- parseField "holder_disclosed_claims" obj >>= parseHolderDisclosedClaims
expectedVerified <- parseField "expect_verified_user_claims" obj >>= parseUserClaims
keyBinding <- parseFieldMaybe "key_binding" obj >>= \case
Just (Bool b) -> Right b
Just _ -> Left "key_binding must be a boolean"
Nothing -> Right False
serializationFormat <- parseFieldMaybe "serialization_format" obj >>= \case
Just (String s) -> Right $ Just s
Just _ -> Left "serialization_format must be a string"
Nothing -> Right Nothing
addDecoyClaims <- parseFieldMaybe "add_decoy_claims" obj >>= \case
Just (Bool b) -> Right b
Just _ -> Left "add_decoy_claims must be a boolean"
Nothing -> Right False
extraHeaderParams <- parseFieldMaybe "extra_header_parameters" obj >>= \case
Just (Object o) -> Right $ KeyMap.toMapText o
Just _ -> Left "extra_header_parameters must be an object"
Nothing -> Right Map.empty
return $ TestCase
{ tcUserClaims = userClaims
, tcHolderDisclosedClaims = holderDisclosed
, tcExpectedVerifiedClaims = expectedVerified
, tcKeyBinding = keyBinding
, tcSerializationFormat = serializationFormat
, tcAddDecoyClaims = addDecoyClaims
, tcExtraHeaderParameters = extraHeaderParams
}
parseTestCase _ = Left "Test case must be a YAML object"
-- | Parse user claims from Aeson Value
--
-- The Python library uses YAML tags like `!sd` to mark selectively disclosable claims.
-- When converted to Aeson, these tags are lost, but we can identify selectively
-- disclosable claims by comparing with holder_disclosed_claims.
parseUserClaims :: Value -> Either String (Map Text Value)
parseUserClaims (Object obj) = Right $ KeyMap.toMapText obj
parseUserClaims _ = Left "user_claims must be an object"
-- | Parse holder disclosed claims from Aeson Value
--
-- The Python format uses boolean flags (True/False) to indicate which claims
-- should be disclosed. For arrays, it's a list of booleans.
parseHolderDisclosedClaims :: Value -> Either String (Map Text Value)
parseHolderDisclosedClaims (Object obj) = Right $ KeyMap.toMapText obj
parseHolderDisclosedClaims _ = Left "holder_disclosed_claims must be an object"
-- | Parse a required field from an Aeson object
parseField :: Text -> Object -> Either String Value
parseField fieldName obj = case KeyMap.lookup (Key.fromText fieldName) obj of
Just v -> Right v
Nothing -> Left $ "Missing required field: " ++ T.unpack fieldName
-- | Parse an optional field from an Aeson object
parseFieldMaybe :: Text -> Object -> Either String (Maybe Value)
parseFieldMaybe fieldName obj = Right $ KeyMap.lookup (Key.fromText fieldName) obj
-- | Extract selectively disclosable paths from YAML file.
--
-- Uses HsYAML's Node representation which preserves tags, allowing us to
-- extract paths that have the !sd tag.
-- | Extract selectively disclosable paths from user_claims in YAML file.
--
-- Uses HsYAML's Node representation which preserves tags, allowing us to
-- extract paths that have the !sd tag. Only extracts paths from user_claims.
extractSelectivelyDisclosablePaths :: FilePath -> IO (Either String [T.Text])
extractSelectivelyDisclosablePaths filePath = do
yamlContent <- BS.readFile filePath
-- Parse to Node to extract tags
case decodeNode (BSL.fromStrict yamlContent) of
Right [doc] -> do
-- Find user_claims in the document and extract paths from it
let rootNode = docRoot doc
userClaimsPaths = extractUserClaimsPaths rootNode []
return $ Right userClaimsPaths
Right _ -> return $ Left "Expected single YAML document"
Left (pos, err) -> return $ Left $ "Failed to parse YAML at " ++ show pos ++ ": " ++ err
-- | Extract paths from user_claims section only
extractUserClaimsPaths :: Node Pos -> [T.Text] -> [T.Text]
extractUserClaimsPaths node currentPath = case node of
Mapping _ _ pairs ->
-- Look for "user_claims" key
concatMap (\(keyNode, valueNode) ->
case keyNode of
Scalar _ (SStr "user_claims") ->
-- Found user_claims, extract paths from its value (which is a mapping)
case valueNode of
Mapping _ _ userPairs ->
-- Extract paths from each top-level claim in user_claims
concatMap (\(claimKeyNode, claimValueNode) ->
let claimName = extractScalarText claimKeyNode
claimPath = if T.null claimName then [] else [claimName]
paths = extractSDPathsFromNode claimValueNode claimPath
in paths
) (Map.toList userPairs)
_ -> extractSDPathsFromNode valueNode []
_ -> []
) (Map.toList pairs)
_ -> []
-- | Extract text from a scalar node
extractScalarText :: Node Pos -> T.Text
extractScalarText node = case node of
Scalar _ (SStr s) -> s
Scalar _ _ -> T.pack (show node)
_ -> T.pack (show node)
-- | Extract paths with !sd tags from HsYAML Node.
--
-- We recursively walk through the Node structure and track the current path.
-- When we encounter a node with tag "!sd", we record the current path.
extractSDPathsFromNode :: Node Pos -> [T.Text] -> [T.Text]
extractSDPathsFromNode node currentPath = case node of
Mapping _ tag pairs -> extractMappingPaths tag pairs currentPath
Sequence _ tag items -> extractSequencePaths tag items currentPath
Scalar _ (SUnknown tag _) -> extractScalarSDPath tag currentPath
Scalar _ _ -> []
Anchor _ _ innerNode -> extractSDPathsFromNode innerNode currentPath
extractMappingPaths :: (Show a) => a -> Mapping Pos -> [T.Text] -> [T.Text]
extractMappingPaths tag pairs currentPath =
let isSD = isSDTag tag
childPaths = concatMap processPair (Map.toList pairs)
processPair (keyNode, valueNode) =
let keyPath = extractKeyPath keyNode currentPath
-- Check if the key itself has !sd tag (use currentPath, extractKeySDPaths adds the key itself)
keySDPaths = extractKeySDPaths keyNode currentPath
-- Extract paths from the value (use keyPath which includes the key)
valuePaths = extractSDPathsFromNode valueNode keyPath
in keySDPaths ++ valuePaths
in if isSD && not (null currentPath)
then T.intercalate "/" currentPath : childPaths
else childPaths
-- | Extract paths if a key node has !sd tag
extractKeySDPaths :: Node Pos -> [T.Text] -> [T.Text]
extractKeySDPaths keyNode currentPath = case keyNode of
Scalar _ (SUnknown tag _) | isSDTag tag ->
-- Key has !sd tag, record the path
let keyText = extractScalarTextFromNode keyNode
fullPath = currentPath ++ [keyText]
in if not (T.null keyText)
then [T.intercalate "/" fullPath]
else []
_ -> []
extractSequencePaths :: (Show a) => a -> [Node Pos] -> [T.Text] -> [T.Text]
extractSequencePaths tag items currentPath =
let isSD = isSDTag tag
childPaths = concatMap processItem (zip [0..] items)
processItem (idx, itemNode) =
extractSDPathsFromNode itemNode (currentPath ++ [T.pack (show idx)])
in if isSD && not (null currentPath)
then T.intercalate "/" currentPath : childPaths
else childPaths
extractScalarSDPath :: (Show a) => a -> [T.Text] -> [T.Text]
extractScalarSDPath tag currentPath =
if isSDTag tag && not (null currentPath)
then [T.intercalate "/" currentPath]
else []
-- | Extract key path from a mapping key node
extractKeyPath :: Node Pos -> [T.Text] -> [T.Text]
extractKeyPath keyNode currentPath =
let keyText = extractScalarTextFromNode keyNode
in if not (T.null keyText)
then currentPath ++ [keyText]
else currentPath
-- | Check if a tag is the !sd tag
-- Tag can be Maybe Tag (for Mapping/Sequence) or Tag (for SUnknown scalars)
isSDTag :: (Show a) => a -> Bool
isSDTag tag = let tagText = T.pack (show tag)
in tagText == "!sd" || tagText == "Just \"!sd\"" || T.isSuffixOf "!sd" tagText