packages feed

crypto-rng-0.3.0.2: test/Main.hs

{-# LANGUAGE CPP #-}
module Main (main) where

import Control.Exception
import Control.Monad
import Data.ByteString qualified as BS
import Data.Set qualified as S
import Test.Tasty
import Test.Tasty.HUnit

import Crypto.RNG
import Crypto.RNG.Utils

#if MIN_VERSION_random(1,3,0)
import Data.ByteString.Short qualified as SBS
import Data.Primitive.ByteArray
import System.Random.Stateful qualified as R
#endif

main :: IO ()
main = defaultMain $ testGroup "crypto-rng"
  [ testGroup "randomBytesIO" $ map bufferRefill configurations
  , testGroup "randomString"
    [ testCase "draws from the allowed chars" $ do
        rng <- newCryptoRNGState
        s <- runCryptoRNGT rng $ randomString 1000 alphabet
        assertEqual "length" 1000 (length s)
        assertBool "every char is allowed" $ all (`elem` alphabet) s
        assertBool "the whole alphabet shows up" $ all (`elem` s) alphabet
    , testCase "rejects an empty list of allowed chars" $ do
        rng <- newCryptoRNGState
        r <- try @ErrorCall $
          evaluate . length =<< runCryptoRNGT rng (randomString 8 "")
        case r of
          Left _ -> pure ()
          Right len -> assertFailure $ "returned a string of length " ++ show len
    ]
#if MIN_VERSION_random(1,3,0)
  , testGroup "uniformByteArrayM" [byteArrays]
#endif
  ]
  where
    alphabet :: [Char]
    alphabet = ['a' .. 'z'] ++ ['0' .. '9']

#if MIN_VERSION_random(1,3,0)
-- | The conversion from the generated bytes used to prepend a serialized
-- length, so the results were longer than requested and started with bytes
-- that were not random.
byteArrays :: TestTree
byteArrays = testCase "results have the requested length" $ do
  rng <- newCryptoRNGState
  forM_ [0, 1, 8, 16, 100, 1000] $ \n -> do
    forM_ [False, True] $ \isPinned -> do
      ba <- R.uniformByteArrayM isPinned n rng
      assertEqual ("byte array of " ++ show n) n (sizeofByteArray ba)
      when (isPinned && n > 0) $ do
        assertBool ("byte array of " ++ show n ++ " is pinned") (isByteArrayPinned ba)
    sbs <- R.uniformShortByteStringM n rng
    assertEqual ("short byte string of " ++ show n) n (SBS.length sbs)
#endif

-- | Buffer size paired with the request sizes to cycle through.
--
-- A request that is larger than the bytes left in the buffer is what triggers a
-- refill, so in each configuration the request sizes do not divide the buffer
-- size evenly.
configurations :: [(Int, [Int])]
configurations =
  [ (16, [10])
  , (16, [1, 7, 13, 40])
  , (32, [100])
  , (64, [20])
  , (32 * 1024, [100])
  , (1024, [3000, 10])
  , (1, [1, 2, 3])
  ]

-- | A refill used to hand out the bytes of the drained buffer a second time, so
-- a returned chunk repeated its own prefix and the repeat showed up again in
-- the following chunk.
bufferRefill :: (Int, [Int]) -> TestTree
bufferRefill (bufSize, sizes) = testCase name $ do
  rng <- newCryptoRNGStateSized bufSize
  chunks <- forM requestSizes $ \n -> do
    chunk <- randomBytesIO n rng
    assertEqual ("length of a " ++ show n ++ " byte request") n (BS.length chunk)
    assertBool ("a " ++ show n ++ " byte request repeats its own prefix") $
      not (repeatsPrefix 4 chunk)
    pure chunk
  let ws = windows 8 $ BS.concat chunks
  assertEqual "repeated windows" 0 (length ws - S.size (S.fromList ws))
  where
    name :: String
    name = "buffer of " ++ show bufSize ++ " bytes, requests of " ++ show sizes

    -- Enough requests to drain and refill the buffer several times.
    requestSizes :: [Int]
    requestSizes = takeUntilTotal (max 30000 (4 * bufSize)) (cycle sizes)

    takeUntilTotal :: Int -> [Int] -> [Int]
    takeUntilTotal _ [] = []
    takeUntilTotal remaining (n : ns)
      | remaining <= 0 = []
      | otherwise = n : takeUntilTotal (remaining - n) ns

    -- A shift by less than minLen bytes is left out, because a short match
    -- happens by chance often enough.
    repeatsPrefix :: Int -> BS.ByteString -> Bool
    repeatsPrefix minLen chunk = any matches [1 .. BS.length chunk - minLen]
      where
        matches :: Int -> Bool
        matches p = BS.drop p chunk == BS.take (BS.length chunk - p) chunk

    windows :: Int -> BS.ByteString -> [BS.ByteString]
    windows k bs
      | BS.length bs < k = []
      | otherwise = BS.take k bs : windows k (BS.drop 1 bs)