hinagata-core-0.1.0.0: src/Hinagata/Fixture/Bundle.hs
module Hinagata.Fixture.Bundle
( BundleConfig (..),
BundleError (..),
CapturedStep (..),
CapturedFixture (..),
FixturePlan,
planDigest,
planDirectory,
planFixtures,
verifyPlan,
verifyComposedRemainder,
compileFixtures,
CompositionError (..),
CapturedFixtureRef (..),
ComposedPlan (..),
composePlans,
)
where
import Control.Exception (IOException, try)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT, throwE)
import Crypto.Hash.SHA256 qualified as SHA256
import Data.ByteString (ByteString)
import Data.ByteString qualified as ByteString
import Data.ByteString.Builder (Builder, byteString, toLazyByteString, word64BE, word8)
import Data.ByteString.Lazy qualified as Lazy
import Data.List (isPrefixOf)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding
import Data.Word (Word64)
import Hinagata.Fixture.Graph (GraphError, resolveFixtures)
import Hinagata.Fixture.Manifest
import Hinagata.Fixture.SqlPolicy
import Hinagata.Fixture.Types
import Hinagata.Prelude
import Hinagata.Types
import Numeric (showHex)
import System.Directory
( canonicalizePath,
createDirectoryIfMissing,
doesDirectoryExist,
doesFileExist,
getFileSize,
getModificationTime,
pathIsSymbolicLink,
renameDirectory,
)
import System.FilePath (isAbsolute, makeRelative, takeFileName, (</>))
import System.IO (Handle, IOMode (..), withBinaryFile)
import System.IO.Error (isDoesNotExistError)
import System.IO.Temp (withTempDirectory)
data BundleConfig = BundleConfig
{ fixtureRoot :: !FilePath,
bundleRoot :: !FilePath,
sqlSizeLimit :: !Positive,
chunkSize :: !Positive
}
deriving stock (Eq, Show)
data BundleError
= BundleIo !Text
| MissingFixture !FixtureName
| UnsafePath !FilePath
| InvalidFixtureManifest !FixtureName !ManifestError
| InvalidFixtureGraph !GraphError
| SourceChanged !FilePath
| SqlStepTooLarge !FilePath
| InvalidSql !FilePath !SqlPolicyError
deriving stock (Eq, Show)
data CapturedStep
= CapturedSql
{ relativePath :: !FilePath,
contentDigest :: !Text,
byteLength :: !Word64
}
| CapturedCopy
{ copySpec :: !CopySpec,
relativePath :: !FilePath,
contentDigest :: !Text,
byteLength :: !Word64
}
deriving stock (Eq, Show)
data CapturedFixture = CapturedFixture
{ name :: !FixtureName,
includes :: ![FixtureName],
steps :: ![CapturedStep],
identity :: !Text
}
deriving stock (Eq, Show)
data FixturePlan = FixturePlan
{ directory :: !FilePath,
digest :: !Text,
fixtures :: ![CapturedFixture]
}
deriving stock (Eq, Show)
planDigest :: FixturePlan -> Text
planDigest FixturePlan {digest} = digest
planDirectory :: FixturePlan -> FilePath
planDirectory FixturePlan {directory} = directory
planFixtures :: FixturePlan -> [CapturedFixture]
planFixtures FixturePlan {fixtures} = fixtures
-- | Recheck a frozen bundle before a database load uses it. A cached plan is
-- reusable in process, but on-disk bundle bytes may have changed meanwhile.
verifyPlan :: FixturePlan -> IO Bool
verifyPlan FixturePlan {directory, digest, fixtures} =
verifyBundle directory (strictBuilder (manifestEncoding digest fixtures)) fixtures
-- | Verify the bundle manifest and only the scenario fixtures that a clone
-- will load. A shared base prefix was verified when its template was sealed.
verifyComposedRemainder :: FixturePlan -> ComposedPlan -> IO Bool
verifyComposedRemainder FixturePlan {directory, digest, fixtures} ComposedPlan {scenarioRemainder} =
verifyBundle directory (strictBuilder (manifestEncoding digest fixtures)) (map fixture scenarioRemainder)
data CompositionError = ConflictingFixture !FixtureName
deriving stock (Eq, Show)
data CapturedFixtureRef = CapturedFixtureRef
{ directory :: !FilePath,
fixture :: !CapturedFixture
}
deriving stock (Eq, Show)
data ComposedPlan = ComposedPlan
{ basePrefix :: ![CapturedFixtureRef],
scenarioRemainder :: ![CapturedFixtureRef]
}
deriving stock (Eq, Show)
-- | Purely compare frozen fixture identities and remove a verified-equal
-- overlap from the scenario. Only the lifecycle manager can authorize skipping
-- the base prefix on a clone of its sealed baseline.
composePlans :: FixturePlan -> FixturePlan -> Either CompositionError ComposedPlan
composePlans base scenario = do
remainder <- traverse select (planFixtures scenario)
pure
ComposedPlan
{ basePrefix = map (CapturedFixtureRef (planDirectory base)) (planFixtures base),
scenarioRemainder = [reference | Just reference <- remainder]
}
where
baseIdentities = Map.fromList [(fixtureName, fixtureIdentity) | CapturedFixture {name = fixtureName, identity = fixtureIdentity} <- planFixtures base]
select fixture@CapturedFixture {name = fixtureName, identity = fixtureIdentity} =
case Map.lookup fixtureName baseIdentities of
Nothing -> Right (Just (CapturedFixtureRef (planDirectory scenario) fixture))
Just baseIdentity
| baseIdentity == fixtureIdentity -> Right Nothing
| otherwise -> Left (ConflictingFixture fixtureName)
compileFixtures :: BundleConfig -> [FixtureName] -> IO (Either BundleError FixturePlan)
compileFixtures config roots = do
outcome <- try @IOException $ do
root <- canonicalizePath (fixtureRoot config)
validRoot <- doesDirectoryExist root
if not validRoot
then pure (Left (BundleIo "fixture root is not a directory"))
else do
createDirectoryIfMissing True (bundleRoot config)
withTempDirectory (bundleRoot config) "hinagata-capture-" $ \staging ->
runExceptT $ do
definitions <- discover root roots
ordered <- either (throwE . InvalidFixtureGraph) pure (resolveFixtures definitions roots)
captured <- captureAll config root staging ordered
let digest = hashBuilder (planEncoding captured)
manifest = strictBuilder (manifestEncoding digest captured)
lift (ByteString.writeFile (staging </> "manifest.bin") manifest)
published <- lift (publish (bundleRoot config) staging digest manifest captured)
pure FixturePlan {directory = published, digest, fixtures = captured}
pure $ either (Left . BundleIo . Text.pack . show) id outcome
discover :: FilePath -> [FixtureName] -> ExceptT BundleError IO [FixtureDefinition]
discover root = go Set.empty []
where
go _ found [] = pure (reverse found)
go seen found (next : rest)
| Set.member next seen = go seen found rest
| otherwise = do
definition <- loadDefinition root next
let FixtureDefinition {includes = dependencies} = definition
go (Set.insert next seen) (definition : found) (dependencies ++ rest)
loadDefinition :: FilePath -> FixtureName -> ExceptT BundleError IO FixtureDefinition
loadDefinition root fixtureName = do
let directory = root </> Text.unpack (fixtureNameText fixtureName)
exists <- lift (doesDirectoryExist directory)
unless exists (throwE (MissingFixture fixtureName))
canonicalDirectory <- lift (canonicalizePath directory)
unless (within root canonicalDirectory) (throwE (UnsafePath directory))
let manifestPath = canonicalDirectory </> "fixture.yaml"
hasManifest <- lift (doesFileExist manifestPath)
manifestIsLink <- lift $ do
linkResult <- try @IOException (pathIsSymbolicLink manifestPath)
case linkResult of
Right linked -> pure linked
Left failure | isDoesNotExistError failure -> pure False
Left failure -> ioError failure
if hasManifest || manifestIsLink
then do
canonicalManifest <- lift (canonicalizePath manifestPath)
unless (within root canonicalManifest) (throwE (UnsafePath manifestPath))
validManifest <- lift (doesFileExist canonicalManifest)
unless validManifest (throwE (UnsafePath manifestPath))
bytes <- lift (ByteString.readFile canonicalManifest)
either (throwE . InvalidFixtureManifest fixtureName) (pure . manifestDefinition) (decodeManifest fixtureName bytes)
else pure (implicitDefinition fixtureName)
captureAll :: BundleConfig -> FilePath -> FilePath -> [FixtureDefinition] -> ExceptT BundleError IO [CapturedFixture]
captureAll config root staging = go 0
where
go :: Int -> [FixtureDefinition] -> ExceptT BundleError IO [CapturedFixture]
go _ [] = pure []
go nextIndex (definition@FixtureDefinition {name, includes, steps} : rest) = do
(capturedSteps, afterIndex) <- captureSteps nextIndex definition steps
let identity = hashBuilder (fixtureEncoding name includes capturedSteps)
captured = CapturedFixture {name, includes, steps = capturedSteps, identity}
(captured :) <$> go afterIndex rest
captureSteps :: Int -> FixtureDefinition -> [FixtureStep] -> ExceptT BundleError IO ([CapturedStep], Int)
captureSteps nextIndex _ [] = pure ([], nextIndex)
captureSteps nextIndex FixtureDefinition {name} (step : rest) = do
let sourceDirectory = root </> Text.unpack (fixtureNameText name)
relativePath = "step-" ++ show nextIndex
output = staging </> relativePath
input =
sourceDirectory </> case step of
SqlFile path -> path
CopyCsv CopySpec {file} -> file
source <- lift (canonicalizePath input)
exists <- lift (doesFileExist source)
unless (exists && within root source) (throwE (UnsafePath input))
(byteCount, contentDigest) <- copyAndHash config source output (case step of SqlFile {} -> True; CopyCsv {} -> False)
capturedStep <- case step of
SqlFile {} -> do
sql <- lift (ByteString.readFile output)
either (throwE . InvalidSql input) (const (pure ())) (checkSqlPolicy sql)
pure CapturedSql {relativePath, contentDigest, byteLength = byteCount}
CopyCsv spec -> pure CapturedCopy {copySpec = spec, relativePath, contentDigest, byteLength = byteCount}
(remaining, afterIndex) <- captureSteps (nextIndex + 1) FixtureDefinition {name, includes = [], steps = []} rest
pure (capturedStep : remaining, afterIndex)
copyAndHash :: BundleConfig -> FilePath -> FilePath -> Bool -> ExceptT BundleError IO (Word64, Text)
copyAndHash config source output isSql = do
sizeBefore <- lift (getFileSize source)
modifiedBefore <- lift (getModificationTime source)
result <- lift $ withBinaryFile source ReadMode $ \input ->
withBinaryFile output WriteMode $ \destination -> loop input destination 0 SHA256.init
(byteCount, hashContext) <- either throwE pure result
sizeAfter <- lift (getFileSize source)
modifiedAfter <- lift (getModificationTime source)
when (sizeBefore /= sizeAfter || modifiedBefore /= modifiedAfter) (throwE (SourceChanged source))
pure (byteCount, hex (SHA256.finalize hashContext))
where
limit = fromIntegral (positiveValue (sqlSizeLimit config)) :: Word64
chunk = min 1048576 (positiveValue (chunkSize config))
loop :: Handle -> Handle -> Word64 -> SHA256.Ctx -> IO (Either BundleError (Word64, SHA256.Ctx))
loop input destination byteCount context = do
bytes <- ByteString.hGet input chunk
if ByteString.null bytes
then pure (Right (byteCount, context))
else do
let newLength = byteCount + fromIntegral (ByteString.length bytes)
if isSql && newLength > limit
then pure (Left (SqlStepTooLarge source))
else do
ByteString.hPut destination bytes
let nextContext = SHA256.update context bytes
nextContext `seq` loop input destination newLength nextContext
publish :: FilePath -> FilePath -> Text -> ByteString -> [CapturedFixture] -> IO FilePath
publish bundleRoot staging digest manifest captured = do
let preferred = bundleRoot </> ("v1-" ++ Text.unpack digest)
unique = preferred ++ "-" ++ takeFileName staging
exists <- doesDirectoryExist preferred
if exists
then do
valid <- verifyBundle preferred manifest captured
if valid then pure preferred else renameDirectory staging unique >> pure unique
else do
result <- try @IOException (renameDirectory staging preferred)
case result of
Right () -> pure preferred
Left _ -> do
valid <- verifyBundle preferred manifest captured
if valid then pure preferred else renameDirectory staging unique >> pure unique
verifyBundle :: FilePath -> ByteString -> [CapturedFixture] -> IO Bool
verifyBundle directory manifest captured = do
result <- try @IOException $ do
directoryIsLink <- pathIsSymbolicLink directory
if directoryIsLink
then pure False
else do
manifestIsLink <- pathIsSymbolicLink (directory </> "manifest.bin")
if manifestIsLink
then pure False
else do
stored <- ByteString.readFile (directory </> "manifest.bin")
if stored /= manifest
then pure False
else and <$> traverse verifyStep [step | CapturedFixture {steps = capturedSteps} <- captured, step <- capturedSteps]
pure (either (const False) id result)
where
verifyStep step = do
let path = directory </> relativePath step
exists <- doesFileExist path
if not exists
then pure False
else do
isLink <- pathIsSymbolicLink path
if isLink
then pure False
else do
size <- getFileSize path
digest <- hashFile path
pure (size == fromIntegral (byteLength step) && digest == contentDigest step)
hashFile :: FilePath -> IO Text
hashFile path = withBinaryFile path ReadMode (go SHA256.init)
where
go context handle = do
bytes <- ByteString.hGet handle 65536
if ByteString.null bytes
then pure (hex (SHA256.finalize context))
else do
let nextContext = SHA256.update context bytes
nextContext `seq` go nextContext handle
within :: FilePath -> FilePath -> Bool
within root candidate =
let relative = makeRelative root candidate
in not (isAbsolute relative) && relative /= ".." && not ("../" `isPrefixOf` relative)
planEncoding :: [CapturedFixture] -> Builder
planEncoding fixtures = field "hinagata-plan-v1" <> count fixtures <> foldMap (field . identity) fixtures
manifestEncoding :: Text -> [CapturedFixture] -> Builder
manifestEncoding digest fixtures =
field "hinagata-bundle-v1" <> field digest <> count fixtures <> foldMap encodeFixture fixtures
where
encodeFixture CapturedFixture {name, includes, steps, identity} =
field (fixtureNameText name)
<> field identity
<> count includes
<> foldMap (field . fixtureNameText) includes
<> count steps
<> foldMap encodeStepManifest steps
fixtureEncoding :: FixtureName -> [FixtureName] -> [CapturedStep] -> Builder
fixtureEncoding name includes steps =
field "hinagata-fixture-v1"
<> field (fixtureNameText name)
<> count includes
<> foldMap (field . fixtureNameText) includes
<> count steps
<> foldMap encodeStepContent steps
encodeStepManifest :: CapturedStep -> Builder
encodeStepManifest step = field (Text.pack (relativePath step)) <> encodeStepContent step
encodeStepContent :: CapturedStep -> Builder
encodeStepContent CapturedSql {contentDigest, byteLength} =
word8 1 <> field contentDigest <> word64BE byteLength
encodeStepContent CapturedCopy {copySpec = CopySpec {schema, table, columns, file, header}, contentDigest, byteLength} =
word8 2
<> field (Text.pack file)
<> field (sqlIdentifierText schema)
<> field (sqlIdentifierText table)
<> count columns
<> foldMap (field . sqlIdentifierText) columns
<> word8 (if header then 1 else 0)
<> field contentDigest
<> word64BE byteLength
field :: Text -> Builder
field value =
let bytes = Encoding.encodeUtf8 value
in word64BE (fromIntegral (ByteString.length bytes)) <> byteString bytes
count :: [a] -> Builder
count = word64BE . fromIntegral . length
strictBuilder :: Builder -> ByteString
strictBuilder = Lazy.toStrict . toLazyByteString
hashBuilder :: Builder -> Text
hashBuilder = hex . SHA256.hash . strictBuilder
hex :: ByteString -> Text
hex = Text.pack . concatMap twoDigits . ByteString.unpack
where
twoDigits byte = case showHex byte "" of
[digit] -> ['0', digit]
digits -> digits