2026-07-19 23:57:21 +09:00
|
|
|
module Main (main) where
|
|
|
|
|
|
|
|
|
|
import Data.Aeson (Value (..), eitherDecodeStrict')
|
|
|
|
|
import qualified Data.Aeson.Key as Key
|
|
|
|
|
import qualified Data.Aeson.KeyMap as KeyMap
|
|
|
|
|
import qualified Data.ByteString as BS
|
|
|
|
|
import qualified Data.ByteString.Char8 as B8
|
|
|
|
|
import qualified Data.Text as Text
|
|
|
|
|
import Nuthatch.Probe.Assert
|
2026-07-20 23:06:01 +09:00
|
|
|
import Nuthatch.Probe.Http
|
2026-07-19 23:57:21 +09:00
|
|
|
import Nuthatch.Probe.Oidc
|
|
|
|
|
import Nuthatch.Probe.ProductClient
|
|
|
|
|
import System.Environment (getArgs)
|
|
|
|
|
|
|
|
|
|
main :: IO ()
|
2026-07-20 23:06:01 +09:00
|
|
|
main = do
|
|
|
|
|
context <- newProbeContext
|
|
|
|
|
user <- requireEnv "NUTHATCH_OIDC_USER_A"
|
|
|
|
|
session <- login context False user "product"
|
|
|
|
|
getArgs >>= \case
|
|
|
|
|
["verify"] -> verifyProduct context session
|
|
|
|
|
["timeout"] -> verifyTimeout context session
|
|
|
|
|
_ -> fail "expected verify or timeout mode"
|
2026-07-19 23:57:21 +09:00
|
|
|
|
|
|
|
|
verifyTimeout :: ProbeContext -> Session -> IO ()
|
|
|
|
|
verifyTimeout context session = do
|
2026-07-20 23:06:01 +09:00
|
|
|
response <- authenticatedGetWith context session
|
|
|
|
|
defaultRequestOptions
|
|
|
|
|
{ optionQuery =
|
|
|
|
|
[ ("root", Just "personal")
|
|
|
|
|
, ("q", Just "timeout-probe")
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
(probeBase context <> "/api/browse")
|
|
|
|
|
assertEqual "timed search status" 200 (probeStatus response)
|
|
|
|
|
assertContains "timed search bound" "\"partial\":true" (probeBody response)
|
2026-07-19 23:57:21 +09:00
|
|
|
putStrLn "Timed search bound passed"
|
|
|
|
|
|
|
|
|
|
verifyProduct :: ProbeContext -> Session -> IO ()
|
|
|
|
|
verifyProduct context session = do
|
|
|
|
|
verifyApplicationAssets context session
|
|
|
|
|
verifyPaginationAndSearch context session
|
|
|
|
|
verifyCanonicalPaths context session
|
|
|
|
|
verifySafeDownload context session
|
|
|
|
|
verifyRecursiveDelete context session
|
|
|
|
|
putStrLn "Product behavior passed"
|
|
|
|
|
|
|
|
|
|
verifyApplicationAssets :: ProbeContext -> Session -> IO ()
|
|
|
|
|
verifyApplicationAssets context session = do
|
2026-07-20 23:06:01 +09:00
|
|
|
sessionResponse <- authenticatedGet context session
|
|
|
|
|
(probeBase context <> "/api/session")
|
|
|
|
|
assertEqual "session endpoint status" 200 (probeStatus sessionResponse)
|
|
|
|
|
assertContains "session user" "\"user_id\":" (probeBody sessionResponse)
|
2026-07-19 23:57:21 +09:00
|
|
|
assertContains "session CSRF"
|
|
|
|
|
("\"csrf\":\"" <> sessionCsrf session <> "\"")
|
2026-07-20 23:06:01 +09:00
|
|
|
(probeBody sessionResponse)
|
|
|
|
|
application <- authenticatedGet context session (probeBase context <> "/app")
|
|
|
|
|
assertEqual "application status" 200 (probeStatus application)
|
2026-07-19 23:57:21 +09:00
|
|
|
csp <- maybe (fail "application omitted CSP") pure $
|
|
|
|
|
headerValue "content-security-policy" application
|
|
|
|
|
mapM_
|
|
|
|
|
(\directive ->
|
|
|
|
|
assertContains ("application CSP omitted " <> B8.unpack directive)
|
|
|
|
|
directive
|
|
|
|
|
csp)
|
|
|
|
|
["default-src 'none'", "script-src 'self'", "frame-ancestors 'none'"]
|
2026-07-20 23:06:01 +09:00
|
|
|
asset <- between "src=\"/assets/" "\"" (B8.unpack (probeBody application))
|
|
|
|
|
javascript <- authenticatedGet context session
|
|
|
|
|
(probeBase context <> "/assets/" <> asset)
|
|
|
|
|
assertEqual "asset status" 200 (probeStatus javascript)
|
2026-07-19 23:57:21 +09:00
|
|
|
assertContains "asset content type" "javascript" $
|
|
|
|
|
maybe BS.empty id (headerValue "content-type" javascript)
|
|
|
|
|
assertEqual "asset nosniff"
|
|
|
|
|
(Just "nosniff")
|
|
|
|
|
(headerValue "x-content-type-options" javascript)
|
2026-07-20 23:06:01 +09:00
|
|
|
let visible = probeBody application <> probeBody javascript
|
2026-07-19 23:57:21 +09:00
|
|
|
mapM_
|
|
|
|
|
(\value ->
|
|
|
|
|
assert ("browser artifact exposed " <> value) $
|
|
|
|
|
not (B8.pack value `BS.isInfixOf` visible))
|
|
|
|
|
[ "NUTHATCH_S3"
|
|
|
|
|
, "AWS_ACCESS_KEY"
|
|
|
|
|
, "AWS_SECRET_ACCESS_KEY"
|
|
|
|
|
, "client_secret"
|
|
|
|
|
]
|
|
|
|
|
mapM_
|
|
|
|
|
(\marker ->
|
|
|
|
|
assertContains ("asset omitted " <> marker)
|
|
|
|
|
(B8.pack marker)
|
2026-07-20 23:06:01 +09:00
|
|
|
(probeBody javascript))
|
2026-07-19 23:57:21 +09:00
|
|
|
[ "Drop a file here"
|
|
|
|
|
, "Shared root"
|
|
|
|
|
, "Retry same upload"
|
|
|
|
|
, "Retry same delete"
|
|
|
|
|
, "nuthatch-recursive-delete"
|
|
|
|
|
, "URLSearchParams"
|
|
|
|
|
]
|
2026-07-20 23:06:01 +09:00
|
|
|
unauthenticatedApp <- get (probeClient context) (probeBase context <> "/app")
|
|
|
|
|
assertEqual "unauthenticated application" 303 (probeStatus unauthenticatedApp)
|
|
|
|
|
unauthenticatedAsset <- get
|
|
|
|
|
(probeClient context)
|
|
|
|
|
(probeBase context <> "/assets/" <> asset)
|
|
|
|
|
assertEqual "unauthenticated asset" 303 (probeStatus unauthenticatedAsset)
|
2026-07-19 23:57:21 +09:00
|
|
|
|
|
|
|
|
verifyPaginationAndSearch :: ProbeContext -> Session -> IO ()
|
|
|
|
|
verifyPaginationAndSearch context session = do
|
|
|
|
|
uploadSucceeded context session "product-browse/Alpha.txt"
|
|
|
|
|
"81000000-0000-4000-8000-000000000001" "alpha"
|
|
|
|
|
replay <- upload context session "personal" "product-browse/Alpha.txt"
|
|
|
|
|
"81000000-0000-4000-8000-000000000001" "alpha"
|
|
|
|
|
requireSucceeded "product upload replay" replay
|
|
|
|
|
uploadSucceeded context session "product-browse/beta.txt"
|
|
|
|
|
"81000000-0000-4000-8000-000000000002" "beta"
|
|
|
|
|
uploadSucceeded context session "product-browse/gamma.txt"
|
|
|
|
|
"81000000-0000-4000-8000-000000000003" "gamma"
|
2026-07-20 23:06:01 +09:00
|
|
|
firstPage <- authenticatedGetWith context session
|
|
|
|
|
defaultRequestOptions
|
|
|
|
|
{ optionQuery =
|
|
|
|
|
[ ("root", Just "personal")
|
|
|
|
|
, ("prefix", Just "product-browse")
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
(probeBase context <> "/api/browse")
|
2026-07-19 23:57:21 +09:00
|
|
|
requireStatus "first browse page" 200 firstPage
|
|
|
|
|
assertContains "first page Alpha"
|
|
|
|
|
"\"path\":\"product-browse/Alpha.txt\""
|
2026-07-20 23:06:01 +09:00
|
|
|
(probeBody firstPage)
|
2026-07-19 23:57:21 +09:00
|
|
|
assertContains "first page beta"
|
|
|
|
|
"\"path\":\"product-browse/beta.txt\""
|
2026-07-20 23:06:01 +09:00
|
|
|
(probeBody firstPage)
|
2026-07-19 23:57:21 +09:00
|
|
|
assert "first page exceeded configured size" $
|
2026-07-20 23:06:01 +09:00
|
|
|
not ("product-browse/gamma.txt" `BS.isInfixOf` probeBody firstPage)
|
|
|
|
|
continuation <- stringField "continuation" (probeBody firstPage)
|
|
|
|
|
secondPage <- authenticatedGetWith context session
|
|
|
|
|
defaultRequestOptions
|
|
|
|
|
{ optionQuery =
|
|
|
|
|
[ ("root", Just "personal")
|
|
|
|
|
, ("prefix", Just "product-browse")
|
|
|
|
|
, ("continuation", Just (B8.pack continuation))
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
(probeBase context <> "/api/browse")
|
2026-07-19 23:57:21 +09:00
|
|
|
assertContains "second browse page"
|
|
|
|
|
"\"path\":\"product-browse/gamma.txt\""
|
2026-07-20 23:06:01 +09:00
|
|
|
(probeBody secondPage)
|
2026-07-19 23:57:21 +09:00
|
|
|
mapM_
|
|
|
|
|
(\(path, operationId, body) ->
|
|
|
|
|
uploadSucceeded context session path operationId body)
|
|
|
|
|
[ ("000-Needle-A.txt", "81000000-0000-4000-8000-000000000004", "one")
|
|
|
|
|
, ("001-needle-b.txt", "81000000-0000-4000-8000-000000000005", "two")
|
|
|
|
|
, ("002-filler.txt", "81000000-0000-4000-8000-000000000006", "three")
|
|
|
|
|
, ("003-needle-after-bound.txt", "81000000-0000-4000-8000-000000000007", "four")
|
|
|
|
|
]
|
2026-07-20 23:06:01 +09:00
|
|
|
searched <- authenticatedGetWith context session
|
|
|
|
|
defaultRequestOptions
|
|
|
|
|
{ optionQuery =
|
|
|
|
|
[ ("root", Just "personal")
|
|
|
|
|
, ("q", Just "NEEDLE")
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
(probeBase context <> "/api/browse")
|
2026-07-19 23:57:21 +09:00
|
|
|
mapM_
|
2026-07-20 23:06:01 +09:00
|
|
|
(\value -> assertContains "bounded case-insensitive search" value (probeBody searched))
|
2026-07-19 23:57:21 +09:00
|
|
|
[ "\"path\":\"000-Needle-A.txt\""
|
|
|
|
|
, "\"path\":\"001-needle-b.txt\""
|
|
|
|
|
, "\"partial\":true"
|
|
|
|
|
]
|
|
|
|
|
assert "search crossed the configured scan bound" $
|
2026-07-20 23:06:01 +09:00
|
|
|
not ("003-needle-after-bound.txt" `BS.isInfixOf` probeBody searched)
|
2026-07-19 23:57:21 +09:00
|
|
|
|
|
|
|
|
verifyCanonicalPaths :: ProbeContext -> Session -> IO ()
|
|
|
|
|
verifyCanonicalPaths context session = do
|
|
|
|
|
mapM_
|
2026-07-20 23:06:01 +09:00
|
|
|
(\url -> authenticatedGet context session url >>=
|
2026-07-19 23:57:21 +09:00
|
|
|
requireStatus "non-canonical path" 400)
|
|
|
|
|
[ probeBase context <> "/api/browse?root=Personal"
|
|
|
|
|
, probeBase context <> "/api/browse?root=personal&prefix=a//b"
|
|
|
|
|
, probeBase context <> "/api/download?root=personal&path=./file"
|
|
|
|
|
]
|
2026-07-20 23:06:01 +09:00
|
|
|
decomposed <- authenticatedGet context session
|
|
|
|
|
(probeBase context
|
2026-07-19 23:57:21 +09:00
|
|
|
<> "/api/download?root=personal&path=cafe%CC%81.txt"
|
2026-07-20 23:06:01 +09:00
|
|
|
)
|
2026-07-19 23:57:21 +09:00
|
|
|
requireStatus "non-NFC path" 400 decomposed
|
|
|
|
|
uploadSucceeded context session "encoded%2Fname.txt"
|
|
|
|
|
"81000000-0000-4000-8000-000000000008" "encoded"
|
2026-07-20 23:06:01 +09:00
|
|
|
encoded <- authenticatedGet context session
|
|
|
|
|
(probeBase context
|
2026-07-19 23:57:21 +09:00
|
|
|
<> "/api/download?root=personal&path=encoded%252Fname.txt"
|
2026-07-20 23:06:01 +09:00
|
|
|
)
|
|
|
|
|
assertEqual "encoded path body" "encoded" (probeBody encoded)
|
2026-07-19 23:57:21 +09:00
|
|
|
uploadSucceeded context session "café.txt"
|
|
|
|
|
"81000000-0000-4000-8000-000000000014" "unicode"
|
|
|
|
|
unicode <- download context session "personal" "café.txt"
|
2026-07-20 23:06:01 +09:00
|
|
|
assertEqual "Unicode path body" "unicode" (probeBody unicode)
|
2026-07-19 23:57:21 +09:00
|
|
|
assertContains "Unicode filename"
|
|
|
|
|
"filename*=UTF-8''caf%C3%A9.txt"
|
|
|
|
|
(maybe BS.empty id (headerValue "content-disposition" unicode))
|
|
|
|
|
|
|
|
|
|
verifySafeDownload :: ProbeContext -> Session -> IO ()
|
|
|
|
|
verifySafeDownload context session = do
|
|
|
|
|
let svg = "<svg onload=\"alert(1)\"></svg>"
|
|
|
|
|
uploadSucceeded context session "active.svg"
|
|
|
|
|
"81000000-0000-4000-8000-000000000009" svg
|
2026-07-20 23:06:01 +09:00
|
|
|
ranged <- authenticatedGetWith context session
|
|
|
|
|
defaultRequestOptions { optionHeaders = [("Range", "bytes=0-1")] }
|
|
|
|
|
(probeBase context <> "/api/download?root=personal&path=active.svg")
|
2026-07-19 23:57:21 +09:00
|
|
|
requireStatus "unsupported range" 416 ranged
|
|
|
|
|
assertContains "range error"
|
|
|
|
|
"\"error\":\"range_not_supported\""
|
2026-07-20 23:06:01 +09:00
|
|
|
(probeBody ranged)
|
2026-07-19 23:57:21 +09:00
|
|
|
downloaded <- download context session "personal" "active.svg"
|
|
|
|
|
requireStatus "safe download" 200 downloaded
|
|
|
|
|
let disposition =
|
|
|
|
|
maybe BS.empty id (headerValue "content-disposition" downloaded)
|
|
|
|
|
assertContains "download attachment" "attachment;" disposition
|
|
|
|
|
assertContains "download filename" "filename*=UTF-8''active.svg" disposition
|
|
|
|
|
assertEqual "download nosniff"
|
|
|
|
|
(Just "nosniff")
|
|
|
|
|
(headerValue "x-content-type-options" downloaded)
|
|
|
|
|
assertContains "download sandbox"
|
|
|
|
|
"sandbox"
|
|
|
|
|
(maybe BS.empty id (headerValue "content-security-policy" downloaded))
|
2026-07-20 23:06:01 +09:00
|
|
|
assertEqual "download body" svg (probeBody downloaded)
|
2026-07-19 23:57:21 +09:00
|
|
|
|
|
|
|
|
verifyRecursiveDelete :: ProbeContext -> Session -> IO ()
|
|
|
|
|
verifyRecursiveDelete context session = do
|
|
|
|
|
mapM_
|
|
|
|
|
(\(path, operationId, body) ->
|
|
|
|
|
uploadSucceeded context session path operationId body)
|
|
|
|
|
[ ("delete-tree/a.txt", "81000000-0000-4000-8000-000000000010", "a")
|
|
|
|
|
, ("delete-tree/b.txt", "81000000-0000-4000-8000-000000000011", "b")
|
|
|
|
|
, ("delete-tree/c.txt", "81000000-0000-4000-8000-000000000012", "c")
|
|
|
|
|
]
|
|
|
|
|
planned <- deletePlan context session
|
|
|
|
|
assertContains "first delete plan"
|
|
|
|
|
"\"paths\":[\"delete-tree/a.txt\",\"delete-tree/b.txt\"]"
|
2026-07-20 23:06:01 +09:00
|
|
|
(probeBody planned)
|
2026-07-19 23:57:21 +09:00
|
|
|
assert "first delete plan omitted continuation" $
|
2026-07-20 23:06:01 +09:00
|
|
|
not ("\"continuation\":null" `BS.isInfixOf` probeBody planned)
|
2026-07-19 23:57:21 +09:00
|
|
|
let firstBatch =
|
|
|
|
|
"{\"root\":\"personal\",\"csrf\":\""
|
|
|
|
|
<> B8.unpack (sessionCsrf session)
|
|
|
|
|
<> "\",\"items\":["
|
|
|
|
|
<> "{\"path\":\"delete-tree/a.txt\",\"operation_id\":"
|
|
|
|
|
<> "\"82000000-0000-4000-8000-000000000001\"},"
|
|
|
|
|
<> "{\"path\":\"delete-tree/b.txt\",\"operation_id\":"
|
|
|
|
|
<> "\"82000000-0000-4000-8000-000000000002\"}]}"
|
|
|
|
|
first <- deleteBatch context session firstBatch
|
|
|
|
|
requireBatchSucceeded "first delete batch" first
|
|
|
|
|
replay <- deleteBatch context session firstBatch
|
|
|
|
|
requireBatchSucceeded "delete batch replay" replay
|
|
|
|
|
uploadSucceeded context session "delete-tree/d-concurrent.txt"
|
|
|
|
|
"81000000-0000-4000-8000-000000000013" "d"
|
|
|
|
|
restarted <- deletePlan context session
|
|
|
|
|
assertContains "restarted delete plan"
|
|
|
|
|
"\"paths\":[\"delete-tree/c.txt\",\"delete-tree/d-concurrent.txt\"]"
|
2026-07-20 23:06:01 +09:00
|
|
|
(probeBody restarted)
|
2026-07-19 23:57:21 +09:00
|
|
|
let secondBatch =
|
|
|
|
|
"{\"root\":\"personal\",\"csrf\":\""
|
|
|
|
|
<> B8.unpack (sessionCsrf session)
|
|
|
|
|
<> "\",\"items\":["
|
|
|
|
|
<> "{\"path\":\"delete-tree/c.txt\",\"operation_id\":"
|
|
|
|
|
<> "\"82000000-0000-4000-8000-000000000003\"},"
|
|
|
|
|
<> "{\"path\":\"delete-tree/d-concurrent.txt\",\"operation_id\":"
|
|
|
|
|
<> "\"82000000-0000-4000-8000-000000000004\"}]}"
|
|
|
|
|
second <- deleteBatch context session secondBatch
|
|
|
|
|
requireBatchSucceeded "second delete batch" second
|
|
|
|
|
empty <- deletePlan context session
|
2026-07-20 23:06:01 +09:00
|
|
|
assertContains "empty delete plan" "\"paths\":[]" (probeBody empty)
|
2026-07-19 23:57:21 +09:00
|
|
|
assertContains "empty delete continuation"
|
|
|
|
|
"\"continuation\":null"
|
2026-07-20 23:06:01 +09:00
|
|
|
(probeBody empty)
|
2026-07-19 23:57:21 +09:00
|
|
|
|
2026-07-20 23:06:01 +09:00
|
|
|
deletePlan :: ProbeContext -> Session -> IO ProbeResponse
|
2026-07-19 23:57:21 +09:00
|
|
|
deletePlan context session =
|
2026-07-20 23:06:01 +09:00
|
|
|
authenticatedGetWith context session
|
|
|
|
|
defaultRequestOptions
|
|
|
|
|
{ optionQuery =
|
|
|
|
|
[ ("root", Just "personal")
|
|
|
|
|
, ("path", Just "delete-tree")
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
(probeBase context <> "/api/delete-plan")
|
2026-07-19 23:57:21 +09:00
|
|
|
|
|
|
|
|
deleteBatch
|
|
|
|
|
:: ProbeContext
|
|
|
|
|
-> Session
|
|
|
|
|
-> String
|
2026-07-20 23:06:01 +09:00
|
|
|
-> IO ProbeResponse
|
2026-07-19 23:57:21 +09:00
|
|
|
deleteBatch context session body =
|
2026-07-20 23:06:01 +09:00
|
|
|
postWith
|
|
|
|
|
(probeClient context)
|
|
|
|
|
defaultRequestOptions
|
|
|
|
|
{ optionCookies = Just (sessionCookies session)
|
|
|
|
|
, optionHeaders = [("Content-Type", "application/json")]
|
|
|
|
|
, optionBody = ByteBody (B8.pack body)
|
|
|
|
|
}
|
|
|
|
|
(probeBase context <> "/api/delete-batch")
|
2026-07-19 23:57:21 +09:00
|
|
|
|
2026-07-20 23:06:01 +09:00
|
|
|
requireBatchSucceeded :: String -> ProbeResponse -> IO ()
|
2026-07-19 23:57:21 +09:00
|
|
|
requireBatchSucceeded label response = do
|
|
|
|
|
requireStatus label 200 response
|
|
|
|
|
assertEqual label 2 $
|
2026-07-20 23:06:01 +09:00
|
|
|
countOccurrences "\"result\":\"succeeded\"" (probeBody response)
|
2026-07-19 23:57:21 +09:00
|
|
|
|
|
|
|
|
uploadSucceeded
|
|
|
|
|
:: ProbeContext
|
|
|
|
|
-> Session
|
|
|
|
|
-> String
|
|
|
|
|
-> String
|
|
|
|
|
-> BS.ByteString
|
|
|
|
|
-> IO ()
|
|
|
|
|
uploadSucceeded context session path operationId body =
|
|
|
|
|
upload context session "personal" path operationId body >>=
|
|
|
|
|
requireSucceeded ("upload " <> path)
|
|
|
|
|
|
2026-07-20 23:06:01 +09:00
|
|
|
requireSucceeded :: String -> ProbeResponse -> IO ()
|
2026-07-19 23:57:21 +09:00
|
|
|
requireSucceeded label response = do
|
|
|
|
|
requireStatus label 200 response
|
2026-07-20 23:06:01 +09:00
|
|
|
assertContains label "\"result\":\"succeeded\"" (probeBody response)
|
2026-07-19 23:57:21 +09:00
|
|
|
|
2026-07-20 23:06:01 +09:00
|
|
|
requireStatus :: String -> Int -> ProbeResponse -> IO ()
|
2026-07-19 23:57:21 +09:00
|
|
|
requireStatus label expected response =
|
2026-07-20 23:06:01 +09:00
|
|
|
assertEqual (label <> " status") expected (probeStatus response)
|
|
|
|
|
|
|
|
|
|
authenticatedGet
|
|
|
|
|
:: ProbeContext
|
|
|
|
|
-> Session
|
|
|
|
|
-> String
|
|
|
|
|
-> IO ProbeResponse
|
|
|
|
|
authenticatedGet context session =
|
|
|
|
|
authenticatedGetWith context session defaultRequestOptions
|
|
|
|
|
|
|
|
|
|
authenticatedGetWith
|
|
|
|
|
:: ProbeContext
|
|
|
|
|
-> Session
|
|
|
|
|
-> RequestOptions
|
|
|
|
|
-> String
|
|
|
|
|
-> IO ProbeResponse
|
|
|
|
|
authenticatedGetWith context session options =
|
|
|
|
|
getWith
|
|
|
|
|
(probeClient context)
|
|
|
|
|
options { optionCookies = Just (sessionCookies session) }
|
2026-07-19 23:57:21 +09:00
|
|
|
|
|
|
|
|
stringField :: String -> BS.ByteString -> IO String
|
|
|
|
|
stringField name bytes =
|
|
|
|
|
case eitherDecodeStrict' bytes of
|
|
|
|
|
Right (Object object) ->
|
|
|
|
|
case KeyMap.lookup (Key.fromString name) object of
|
|
|
|
|
Just (String value) -> pure (Text.unpack value)
|
|
|
|
|
_ -> fail ("JSON omitted string field " <> name)
|
|
|
|
|
Right _ -> fail "expected JSON object"
|
|
|
|
|
Left reason -> fail ("invalid JSON: " <> reason)
|
|
|
|
|
|
|
|
|
|
between :: String -> String -> String -> IO String
|
|
|
|
|
between start end value =
|
|
|
|
|
case dropUntil start value of
|
|
|
|
|
Nothing -> fail ("value omitted " <> start)
|
|
|
|
|
Just afterStart ->
|
|
|
|
|
case takeUntil end afterStart of
|
|
|
|
|
Nothing -> fail ("value omitted " <> end)
|
|
|
|
|
Just result -> pure result
|
|
|
|
|
|
|
|
|
|
dropUntil :: String -> String -> Maybe String
|
|
|
|
|
dropUntil needle value
|
|
|
|
|
| needle `prefixOf` value = Just (drop (length needle) value)
|
|
|
|
|
dropUntil _ [] = Nothing
|
|
|
|
|
dropUntil needle (_ : rest) = dropUntil needle rest
|
|
|
|
|
|
|
|
|
|
takeUntil :: String -> String -> Maybe String
|
|
|
|
|
takeUntil needle = go []
|
|
|
|
|
where
|
|
|
|
|
go prefix value
|
|
|
|
|
| needle `prefixOf` value = Just (reverse prefix)
|
|
|
|
|
go _ [] = Nothing
|
|
|
|
|
go prefix (next : rest) = go (next : prefix) rest
|
|
|
|
|
|
|
|
|
|
prefixOf :: String -> String -> Bool
|
|
|
|
|
prefixOf prefix value =
|
|
|
|
|
take (length prefix) value == prefix
|
|
|
|
|
|
|
|
|
|
countOccurrences :: BS.ByteString -> BS.ByteString -> Int
|
|
|
|
|
countOccurrences needle input
|
|
|
|
|
| BS.null input = 0
|
|
|
|
|
| needle `BS.isPrefixOf` input =
|
|
|
|
|
1 + countOccurrences needle (BS.drop (BS.length needle) input)
|
|
|
|
|
| otherwise = countOccurrences needle (BS.drop 1 input)
|