packages feed

hurl-workbench-core-0.1.0.0: src/HurlWorkbench/Parameter/Generate.hs

-- | Bounded generation of scenario inputs. No generated data enters errors.
module HurlWorkbench.Parameter.Generate
  ( GeneratorEnvironment (..),
    defaultGeneratorEnvironment,
    generateValue,
    validateGenerator,
    generatorName,
    uuid4Bytes,
    uuid7Bytes,
    formatUuid,
    encodeTypeId,
  )
where

import Control.Exception (IOException, try)
import Data.Bifunctor (first)
import Data.Bits (shiftR, (.&.), (.|.))
import Data.ByteString qualified as BS
import Data.Text qualified as Text
import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime)
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
import Data.Word (Word8)
import HurlWorkbench.Prelude
import HurlWorkbench.Workspace.Types
import Numeric (showHex)
import System.IO (IOMode (ReadMode), withBinaryFile)

data GeneratorEnvironment = GeneratorEnvironment
  { currentTime :: IO UTCTime,
    entropyBytes :: Int -> IO BS.ByteString
  }

defaultGeneratorEnvironment :: GeneratorEnvironment
defaultGeneratorEnvironment = GeneratorEnvironment getCurrentTime $ \count ->
  if count < 0 || count > 16384
    then ioError (userError "invalid entropy request")
    else withBinaryFile "/dev/urandom" ReadMode $ \handle ->
      let readExactly remaining chunks
            | remaining == 0 = pure (BS.concat (reverse chunks))
            | otherwise = do
                bytes <- BS.hGet handle remaining
                if BS.null bytes
                  then ioError (userError "entropy EOF")
                  else readExactly (remaining - BS.length bytes) (bytes : chunks)
       in readExactly count []

generatorName :: Generator -> Text
generatorName = \case
  Uuid4 -> "Uuid4"
  Uuid7 -> "Uuid7"
  TypeId _ -> "TypeId"
  Rfc3339Now -> "Rfc3339Now"
  RandomAlphanumeric _ -> "RandomAlphanumeric"

validateGenerator :: Generator -> Either Text ()
validateGenerator = \case
  TypeId (TypeIdOptions prefix)
    | Text.length prefix > 63
        || not (Text.all (\c -> letter c || c == '_') prefix)
        || (not (Text.null prefix) && (not (letter (Text.head prefix)) || not (letter (Text.last prefix)))) ->
        Left "TypeId prefix must be empty or at most 63 lowercase ASCII letters/underscores, starting and ending with a letter"
  RandomAlphanumeric (RandomOptions count) | count < 1 || count > 4096 -> Left "RandomAlphanumeric length must be between 1 and 4096"
  _ -> Right ()
  where
    letter c = c >= 'a' && c <= 'z'

-- | Synchronous IO failures are redacted; cancellation propagates normally.
generateValue :: GeneratorEnvironment -> Generator -> IO (Either Text HurlValueLiteral)
generateValue env generator = case validateGenerator generator of
  Left err -> pure (Left err)
  Right () -> do
    result <- try @IOException generate
    pure $ case result of
      Left _ -> Left "generation failed"
      Right (Left _) -> Left "generation failed"
      Right (Right value) -> first (const "generated value is not a Hurl literal") (mkHurlValueLiteral value)
  where
    draw count = do
      bytes <- entropyBytes env count
      if BS.length bytes == count then pure bytes else ioError (userError "short entropy read")
    uuid7 = do
      now <- currentTime env
      bytes <- BS.unpack <$> draw 16
      pure (uuid7Bytes (floor (utcTimeToPOSIXSeconds now * 1000)) bytes)
    generate = case generator of
      Uuid4 -> fmap (fmap formatUuid . uuid4Bytes . BS.unpack) (draw 16)
      Uuid7 -> fmap (fmap formatUuid) uuid7
      TypeId (TypeIdOptions prefix) -> fmap (fmap (\bytes -> (if Text.null prefix then "" else prefix <> "_") <> encodeTypeId bytes)) uuid7
      Rfc3339Now -> do
        now <- currentTime env
        let millis = (floor (utcTimeToPOSIXSeconds now * 1000) :: Integer) `mod` 1000
            digits = show millis
        pure (Right (Text.pack (formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%S" now <> "." <> replicate (3 - length digits) '0' <> digits <> "Z")))
      RandomAlphanumeric (RandomOptions count) -> sample (fromIntegral count) (fromIntegral count * 32 + 128) []
    -- Rejection sampling has a finite entropy budget, including a broken source
    -- that supplies only rejected bytes. No modulo bias or unbounded loop.
    sample remaining budget chunks
      | remaining == 0 = pure (Right (Text.concat (reverse chunks)))
      | budget <= 0 = pure (Left "entropy rejection budget exhausted")
      | otherwise = do
          let count = min budget (max 32 (min 4096 (remaining * 2)))
          bytes <- draw count
          let accepted = take remaining [Text.index alphabet (fromIntegral byte `mod` 62) | byte <- BS.unpack bytes, byte < 248]
          sample (remaining - length accepted) (budget - count) (Text.pack accepted : chunks)
    alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"

uuid4Bytes :: [Word8] -> Either Text [Word8]
uuid4Bytes = setVersion 4

setVersion :: Word8 -> [Word8] -> Either Text [Word8]
setVersion version bytes
  | length bytes /= 16 = Left "UUID requires 16 bytes"
  | otherwise = Right [if index == 6 then (byte .&. 15) .|. (version * 16) else if index == 8 then (byte .&. 63) .|. 128 else byte | (index, byte) <- zip [0 :: Int ..] bytes]

uuid7Bytes :: Integer -> [Word8] -> Either Text [Word8]
uuid7Bytes millis bytes
  | millis < 0 || millis >= 2 ^ (48 :: Int) = Left "UUID7 timestamp outside 48-bit range"
  | length bytes /= 16 = Left "UUID requires 16 bytes"
  | otherwise = setVersion 7 ([fromIntegral (millis `shiftR` bits) | bits <- [40, 32 .. 0]] <> drop 6 bytes)

formatUuid :: [Word8] -> Text
formatUuid bytes = Text.pack (concat [separator index <> hex byte | (index, byte) <- zip [0 :: Int ..] bytes])
  where
    separator index = if index `elem` [4, 6, 8, 10] then "-" else ""
    hex byte = let digits = showHex byte "" in replicate (2 - length digits) '0' <> digits

-- | TypeID v0.3: mori://jetify-com/typeid, spec/README.md (artifact URI pending).
encodeTypeId :: [Word8] -> Text
encodeTypeId bytes = Text.pack [alphabet !! fromIntegral ((value `shiftR` bits) .&. 31) | bits <- [125, 120 .. 0]]
  where
    value = foldl (\acc byte -> acc * 256 + fromIntegral byte) (0 :: Integer) bytes
    alphabet = "0123456789abcdefghjkmnpqrstvwxyz"