hinagata-core-0.1.0.0: src/Hinagata/Fixture/SqlPolicy.hs
module Hinagata.Fixture.SqlPolicy
( SqlPolicyError (..),
checkSqlPolicy,
)
where
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as ByteString
import Data.Char (toLower)
import Data.List (isPrefixOf)
import Data.Text qualified as Text
import Hinagata.Prelude
data SqlPolicyError
= ForbiddenStatement !Text
| PsqlCommand
| UnterminatedSqlLiteral !Text
| SqlContainsNul
deriving stock (Eq, Show)
data Prefix = AfterStart | AfterPrepare
deriving stock (Eq, Show)
-- | A lexical policy check for trusted SQL, not a PostgreSQL parser. The
-- caller executes the original bytes unchanged after this preflight.
checkSqlPolicy :: ByteString -> Either SqlPolicyError ()
checkSqlPolicy bytes
| ByteString.elem '\0' bytes = Left SqlContainsNul
| otherwise = scan True True Nothing (ByteString.unpack bytes)
scan :: Bool -> Bool -> Maybe Prefix -> String -> Either SqlPolicyError ()
scan _ _ _ [] = Right ()
scan statementStart lineStart prefix input@(character : rest)
| character == '\n' = scan statementStart True prefix rest
| character `elem` [' ', '\t', '\r', '\f', '\v'] = scan statementStart lineStart prefix rest
| "--" `isPrefixOf` input = scan statementStart True prefix (dropLine (drop 2 input))
| "/*" `isPrefixOf` input = do
(remaining, nextLineStart) <- dropBlock 1 lineStart (drop 2 input)
scan statementStart nextLineStart prefix remaining
| character == '\\' && lineStart = Left PsqlCommand
| character == ';' = scan True False Nothing rest
| character == '\'' = do
remaining <- dropSingle False rest
scan False False Nothing remaining
| character == '"' = do
remaining <- dropDouble rest
scan False False Nothing remaining
| character == '$',
Just (delimiter, afterOpen) <- dollarOpen input = do
remaining <- dropDollar delimiter afterOpen
scan False False Nothing remaining
| identifierStart character =
let (word, remaining) = span identifierContinue input
in case remaining of
'\'' : afterOpen | map toLower word == "e" -> do
afterClose <- dropSingle True afterOpen
scan False False Nothing afterClose
_ -> case classify statementStart prefix (map toLower word) of
Left failure -> Left failure
Right (nextStart, nextPrefix) -> scan nextStart False nextPrefix remaining
| otherwise = scan False False Nothing rest
classify :: Bool -> Maybe Prefix -> String -> Either SqlPolicyError (Bool, Maybe Prefix)
classify _ (Just _) "transaction" = Left (ForbiddenStatement "TRANSACTION")
classify _ (Just _) _ = Right (False, Nothing)
classify False Nothing _ = Right (False, Nothing)
classify True Nothing word
| word `elem` ["begin", "commit", "end", "rollback", "abort", "savepoint", "release", "copy"] =
Left (ForbiddenStatement (Text.pack word))
| word == "start" = Right (False, Just AfterStart)
| word == "prepare" = Right (False, Just AfterPrepare)
| otherwise = Right (False, Nothing)
identifierStart :: Char -> Bool
identifierStart character = asciiLetter character || character == '_'
identifierContinue :: Char -> Bool
identifierContinue character = identifierStart character || (character >= '0' && character <= '9') || character == '$'
asciiLetter :: Char -> Bool
asciiLetter character = (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z')
dropLine :: String -> String
dropLine [] = []
dropLine ('\n' : rest) = rest
dropLine (_ : rest) = dropLine rest
dropBlock :: Int -> Bool -> String -> Either SqlPolicyError (String, Bool)
dropBlock _ _ [] = Left (UnterminatedSqlLiteral "block comment")
dropBlock depth lineStart input
| "/*" `isPrefixOf` input = dropBlock (depth + 1) lineStart (drop 2 input)
| "*/" `isPrefixOf` input =
if depth == 1
then Right (drop 2 input, lineStart)
else dropBlock (depth - 1) lineStart (drop 2 input)
| otherwise = case input of
'\n' : rest -> dropBlock depth True rest
_ : rest -> dropBlock depth lineStart rest
dropSingle :: Bool -> String -> Either SqlPolicyError String
dropSingle _ [] = Left (UnterminatedSqlLiteral "single-quoted string")
dropSingle escaped ('\\' : _ : rest) | escaped = dropSingle escaped rest
dropSingle escaped ('\'' : '\'' : rest) = dropSingle escaped rest
dropSingle _ ('\'' : rest) = Right rest
dropSingle escaped (_ : rest) = dropSingle escaped rest
dropDouble :: String -> Either SqlPolicyError String
dropDouble [] = Left (UnterminatedSqlLiteral "quoted identifier")
dropDouble ('"' : '"' : rest) = dropDouble rest
dropDouble ('"' : rest) = Right rest
dropDouble (_ : rest) = dropDouble rest
dollarOpen :: String -> Maybe (String, String)
dollarOpen ('$' : rest) =
let (tag, afterTag) = span (\character -> asciiLetter character || (character >= '0' && character <= '9') || character == '_') rest
validTag = case tag of
[] -> True
first : _ -> identifierStart first
in case afterTag of
'$' : body | validTag -> Just ('$' : tag ++ "$", body)
_ -> Nothing
dollarOpen _ = Nothing
dropDollar :: String -> String -> Either SqlPolicyError String
dropDollar _ [] = Left (UnterminatedSqlLiteral "dollar-quoted string")
dropDollar delimiter input
| delimiter `isPrefixOf` input = Right (drop (length delimiter) input)
| otherwise = dropDollar delimiter (drop 1 input)