wireform-parquet-0.1.0.0: test/EncryptedWriterRegression.hs
{-# LANGUAGE PatternSynonyms #-}
module EncryptedWriterRegression (tests) where
import Arrow.Types qualified as AT
import Control.Monad (forM_, unless)
import Data.Bits (xor)
import Data.ByteString qualified as BS
import Data.Either (isLeft)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Vector qualified as V
import Data.Vector.Primitive qualified as VP
import Parquet.Arrow qualified as A
import Parquet.BloomFilter qualified as B
import Parquet.Compress qualified as C
import Parquet.Decryption qualified as D
import Parquet.Encryption qualified as E
import Parquet.HighLevel qualified as H
import Parquet.Nested qualified as N
import Parquet.PageIndex qualified as I
import Parquet.Read qualified as R
import Parquet.Types
import Parquet.Write qualified as W
import System.Directory (createDirectoryIfMissing)
import System.Environment (lookupEnv)
import System.FilePath ((</>))
tests :: IO ()
tests = do
output <- lookupEnv "WIREFORM_PARQUET_PRIMITIVE_OUTPUT"
forM_ output $ \root -> createDirectoryIfMissing True (root </> "encryption-writer")
forM_ [Uncompressed, GZip, Snappy, ZSTD, LZ4Raw, LZO, Brotli] $ \codec -> case C.validateCompression codec of
Left _ -> pure ()
Right () -> forM_ [W.PageV1, W.PageV2] $ \version -> do
let base = H.defaultWriteOptions {H.writeCompression = codec, H.writePageVersion = version, H.writeBloomFilters = ["secret", "record", "mapping"]}
nestedPlain <- right (H.encodeParquetNestedWith base nestedSchema nestedRows)
let mixedBase = base {H.writeBloomFilters = ["secret"]}
mixedPlain = H.encodeParquetMixed mixedBase mixedSchema mixedRows
save output ("plain-nested-" ++ show codec ++ "-" ++ show version) nestedPlain
save output ("plain-mixed-" ++ show codec ++ "-" ++ show version) mixedPlain
forM_ [16, 24, 32] $ \width -> forM_ [E.AesGcmV1, E.AesGcmCtrV1] $ \algorithm ->
forM_ [H.EncryptedFooter, H.SignedPlaintextFooter] $ \mode -> forM_ [False, True] $ \split -> do
footer <- W.newFooterEncryption (BS.replicate width 0x42) "writer-regression" "kf"
let encryption ordinal =
W.ColumnEncryption
algorithm
(BS.replicate width (if split then if even ordinal then 0x43 else 0x44 else 0x42))
(W.feFileId footer)
(W.feAadPrefix footer)
(if even ordinal then "kc1" else "kc2")
ordinal
configs count = V.generate count (\col -> if col == 0 then Nothing else Just (encryption col))
nestedOptions = base {H.writeFooterMode = mode, H.writeFooterEncryption = Just footer, H.writeColumnEncryption = configs 6}
mixedOptions = mixedBase {H.writeFooterMode = mode, H.writeFooterEncryption = Just footer, H.writeColumnEncryption = configs 3}
suffix = "-" ++ show width ++ "-" ++ show algorithm ++ "-" ++ show mode ++ "-" ++ (if split then "split" else "footer") ++ "-" ++ show codec ++ "-" ++ show version
config = D.defaultDecryptionConfig {D.dcKeyMetadataKeys = Map.fromList [("kf", BS.replicate width 0x42), ("kc1", BS.replicate width 0x43), ("kc2", BS.replicate width 0x44)], D.dcAadPrefix = Just "writer-regression"}
nested <- H.encodeParquetNestedIO nestedOptions nestedSchema nestedRows >>= right
mixed <- H.encodeParquetMixedIO mixedOptions mixedSchema mixedRows >>= right
verify config nestedPlain nested
verify config mixedPlain mixed
save output ("feature-nested" ++ suffix) nested
save output ("feature-mixed" ++ suffix) mixed
forM_ [nested, mixed] $ \bytes -> do
let damaged = BS.take (BS.length bytes - 9) bytes <> BS.singleton (BS.index bytes (BS.length bytes - 9) `xor` 1) <> BS.drop (BS.length bytes - 8) bytes
check "altered footer/signature is rejected" (isLeft (R.loadParquetFileWithDecryption config damaged))
file <- right (R.loadParquetFileWithDecryption config bytes)
let withoutSecrets = config {D.dcKeyMetadataKeys = Map.singleton "kf" (BS.replicate width 0x42)}
projection <- right (R.loadParquetFileWithDecryption withoutSecrets bytes)
schema <- right (A.parquetFileArrowSchemaChecked projection)
let public = schema {AT.arrowFields = V.filter ((== "public") . AT.fieldName) (AT.arrowFields schema)}
_ <- either (fail . show) pure (A.parquetRowGroupToArrow public projection 0)
whenSplit split $ do
check "missing separate key fails selected secret" (isLeft (R.columnChunkSlice projection 0 1))
let wrong = config {D.dcKeyMetadataKeys = Map.insert "kc2" (BS.replicate width 0) (D.dcKeyMetadataKeys config)}
bad <- right (R.loadParquetFileWithDecryption wrong bytes)
check "wrong separate key fails authentication" (isLeft (R.columnChunkSlice bad 0 1))
check "requested encryption is present" (R.isColumnEncrypted file 0 1 && not (R.isColumnEncrypted file 0 0))
invalidRequests
putStrLn "Encrypted writer feature regressions passed: nested/nullable, separate keys and signed plaintext footers, all enabled codecs and key widths."
verify :: D.DecryptionConfig -> BS.ByteString -> BS.ByteString -> IO ()
verify config plaintext bytes = do
expected <- right (R.loadParquetFile plaintext)
file <- right (R.loadParquetFileWithDecryption config bytes)
expectedSchema <- right (A.parquetFileArrowSchemaChecked expected)
actualSchema <- right (A.parquetFileArrowSchemaChecked file)
check "schema survives encryption" (actualSchema == expectedSchema)
forM_ [0 .. V.length (fmRowGroups (R.pfFooter expected)) - 1] $ \rg -> do
decoded <- either (fail . show) pure (A.parquetRowGroupToArrow actualSchema file rg)
original <- either (fail . show) pure (A.parquetRowGroupToArrow expectedSchema expected rg)
V.imapM_
( \col field -> do
actual <- right (A.columnArrayToNestedRows field (decoded V.! col))
wanted <- right (A.columnArrayToNestedRows field (original V.! col))
check "nullable/nested values match plaintext control" (actual == wanted)
)
(AT.arrowFields actualSchema)
V.imapM_
( \col _ -> do
actualOffsets <- right (I.readOffsetIndex file rg col)
wantedOffsets <- right (I.readOffsetIndex expected rg col)
check "offset index remains present" (fmap (V.length . oiPageLocations) actualOffsets == fmap (V.length . oiPageLocations) wantedOffsets)
actualIndex <- right (I.readColumnIndex file rg col)
wantedIndex <- right (I.readColumnIndex expected rg col)
check "column index retains every bound/count" (actualIndex == wantedIndex)
actualBloom <- right (B.readBloomFilter file rg col)
wantedBloom <- right (B.readBloomFilter expected rg col)
check "bloom bitsets retain every inserted value" (fmap B.encodeBloomFilter actualBloom == fmap B.encodeBloomFilter wantedBloom)
)
(rgColumns (fmRowGroups (R.pfFooter expected) V.! rg))
nestedSchema :: V.Vector (Text, N.NestedSchema)
nestedSchema =
V.fromList
[ ("public", N.NSRequired (N.NSPrimitive N.LtInt32))
, ("secret", N.NSOptional (N.NSList (N.NSOptional (N.NSPrimitive N.LtInt32))))
, ("record", N.NSOptional (N.NSStruct (V.fromList [("a", N.NSOptional (N.NSPrimitive N.LtString)), ("b", N.NSRequired (N.NSPrimitive N.LtInt64))])))
, ("mapping", N.NSOptional (N.NSMap (N.NSRequired (N.NSPrimitive N.LtString)) (N.NSOptional (N.NSPrimitive N.LtInt64))))
]
nestedRows :: V.Vector (V.Vector N.NestedRow)
nestedRows =
V.fromList
[ V.fromList (map (N.NRLeaf . N.LvInt32) [1, 2, 3])
, V.fromList [N.NRNull, N.NRList V.empty, N.NRList (V.fromList [N.NRNull, N.NRLeaf (N.LvInt32 (-7)), N.NRLeaf (N.LvInt32 42)])]
, V.fromList [N.NRNull, N.NRStruct (V.fromList [N.NRNull, N.NRLeaf (N.LvInt64 11)]), N.NRStruct (V.fromList [N.NRLeaf (N.LvString "hi"), N.NRLeaf (N.LvInt64 12)])]
, V.fromList [N.NRNull, N.NRMapEntries V.empty, N.NRMapEntries (V.fromList [(N.NRLeaf (N.LvString "k"), N.NRNull), (N.NRLeaf (N.LvString "z"), N.NRLeaf (N.LvInt64 99))])]
]
mixedSchema :: V.Vector SchemaElement
mixedSchema =
V.fromList
[ SchemaElement "schema" Nothing Nothing (Just 3) Nothing Nothing Nothing Nothing
, SchemaElement "public" (Just Required) (Just PTInt32) Nothing Nothing Nothing Nothing Nothing
, SchemaElement "secret" (Just Optional) (Just PTInt32) Nothing Nothing Nothing Nothing Nothing
, SchemaElement "text" (Just Optional) (Just PTByteArray) Nothing Nothing (Just LTString) Nothing Nothing
]
mixedRows :: [V.Vector W.ParquetColumn]
mixedRows = map group [0, 10]
where
group base = V.fromList [W.PCRequired (W.ColInt32 (VP.fromList [base + 1, base + 2, base + 3])), W.PCOptional (W.OptInt32 (V.fromList [Nothing, Just (-7), Just 42])), W.PCOptional (W.OptByteArray (V.fromList [Just "", Nothing, Just "private"]))]
invalidRequests :: IO ()
invalidRequests = do
let signed = H.defaultWriteOptions {H.writeFooterMode = H.SignedPlaintextFooter, H.writeCompression = Uncompressed}
absent <- H.encodeParquetNestedIO signed nestedSchema nestedRows
check "signed plaintext mode requires a signing key" (isLeft absent)
footer <- W.newFooterEncryption (BS.replicate 16 0x42) "" "kf"
let wrong = signed {H.writeFooterEncryption = Just footer {W.feKey = "bad"}}
bad <- H.encodeParquetMixedIO wrong mixedSchema []
check "empty mixed writer validates footer key" (isLeft bad)
invalid <- H.encodeParquetNestedIO signed {H.writeFooterEncryption = Just footer, H.writeColumnEncryption = V.singleton Nothing} nestedSchema nestedRows
check "nested writer rejects truncated encryption vectors" (isLeft invalid)
save :: Maybe FilePath -> String -> BS.ByteString -> IO ()
save output name bytes = forM_ output $ \root -> BS.writeFile (root </> "encryption-writer" </> (name ++ ".parquet")) bytes
whenSplit :: Bool -> IO () -> IO ()
whenSplit True action = action
whenSplit False _ = pure ()
check :: String -> Bool -> IO ()
check message valid = unless valid (fail ("EncryptedWriterRegression: " ++ message))
right :: Either String a -> IO a
right = either fail pure