packages feed

hinagata-core (empty) → 0.1.0.0

raw patch · 15 files changed

+2034/−0 lines, 15 filesdep +aesondep +basedep +bytestring

Dependencies added: aeson, base, bytestring, containers, cryptohash-sha256, directory, filepath, hinagata-core, lens, settei, settei-env, temporary, text, time, transformers, unix, yaml

Files

+ CHANGELOG.md view
@@ -0,0 +1,20 @@+# Changelog++## 0.1.0.0 - 2026-10-18++### New Features++- Compile ordered SQL and CSV fixtures with dependency resolution, manifest validation, path containment, and deterministic plan identities.+- Capture verified fixture bundles with SHA-256 digests, bounded streaming, and SQL size and transaction-policy checks.+- Expose validated identifiers, explicit connection descriptions, structured errors, and typed configuration declarations with secret redaction.+- Derive baseline hook revisions from declared executable bytes, files, and non-secret values.++### Bug Fixes++- Reject transaction-control statements using all PostgreSQL whitespace characters.+- Prevent ambient host-address settings from overriding explicit connection targets.+- Close interrupted capture resources and remove incomplete bundles on cancellation.++### Other Changes++- Include package changelog and repository metadata, and bound the test suite's dependency on hinagata-core to the shared release series.
+ LICENSE view
@@ -0,0 +1,28 @@+BSD 3-Clause License++Copyright (c) 2026, Hinagata contributors++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+   list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software without+   specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ hinagata-core.cabal view
@@ -0,0 +1,81 @@+cabal-version: 3.12+name: hinagata-core+version: 0.1.0.0+synopsis: Pure fixture plans and configuration for Hinagata+description: Deterministic fixture planning for PostgreSQL test databases.+category: Testing+license: BSD-3-Clause+license-file: LICENSE+author: Nadeem Bitar+maintainer: nadeem@gmail.com+homepage: https://github.com/shinzui/hinagata+bug-reports: https://github.com/shinzui/hinagata/issues+extra-doc-files: CHANGELOG.md+build-type: Simple++common common+  default-language: GHC2024+  default-extensions:+    DeriveAnyClass+    DuplicateRecordFields+    OverloadedLabels+    OverloadedStrings++  ghc-options: -Wall++library+  import: common+  hs-source-dirs: src+  exposed-modules:+    Hinagata.Config+    Hinagata.Connection+    Hinagata.Error+    Hinagata.Fixture.Bundle+    Hinagata.Fixture.Graph+    Hinagata.Fixture.Manifest+    Hinagata.Fixture.SqlPolicy+    Hinagata.Fixture.Types+    Hinagata.Hook.Revision+    Hinagata.Prelude+    Hinagata.Types++  build-depends:+    aeson >=2.2 && <2.4,+    base >=4.21 && <5,+    bytestring >=0.12 && <0.14,+    containers >=0.7 && <0.9,+    cryptohash-sha256 >=0.11.102.1 && <0.12,+    directory >=1.3 && <1.5,+    filepath >=1.5 && <1.7,+    lens >=5.3 && <5.4,+    settei >=0.2 && <0.3,+    settei-env >=0.2 && <0.3,+    temporary >=1.3 && <1.4,+    text >=2.1 && <2.3,+    time >=1.14 && <1.16,+    transformers >=0.6 && <0.8,+    unix >=2.8 && <2.9,+    yaml >=0.11.11.2 && <0.12,++test-suite hinagata-core-test+  import: common+  ghc-options: -rtsopts+  type: exitcode-stdio-1.0+  hs-source-dirs: test+  main-is: Main.hs+  build-depends:+    base >=4.21 && <5,+    bytestring >=0.12 && <0.14,+    directory >=1.3 && <1.5,+    filepath >=1.5 && <1.7,+    hinagata-core ^>=0.1.0.0,+    settei >=0.2 && <0.3,+    settei-env >=0.2 && <0.3,+    temporary >=1.3 && <1.4,+    text >=2.1 && <2.3,+    unix >=2.8 && <2.9,++source-repository head+  type: git+  location: https://github.com/shinzui/hinagata.git+  subdir: hinagata-core
+ src/Hinagata/Config.hs view
@@ -0,0 +1,308 @@+-- | Pure, inspectable Settei declarations for Hinagata's shared settings.+-- Sources are supplied explicitly by the caller; this module performs no IO.+module Hinagata.Config+  ( HinagataConfig (..),+    EndpointConfig (..),+    AccessConfig (..),+    DatabaseGrant (..),+    SchemaPrivilege (..),+    SchemaGrant (..),+    RoleSetting (..),+    CloneStrategy (..),+    accessTarget,+    hinagataConfig,+    hinagataEnvironmentBindings,+  )+where++import Data.Text qualified as Text+import Hinagata.Connection+import Hinagata.Prelude+import Hinagata.Types+import Settei qualified+import Settei.Env qualified as Env+import System.FilePath (isAbsolute)++-- | Connection transport shared by the three independently declared roles.+data EndpointConfig = EndpointConfig+  { host :: !Host,+    port :: !Port+  }+  deriving stock (Eq, Show)++-- | One explicitly selected role and database. Passwords remain secret-safe.+data AccessConfig = AccessConfig+  { user :: !Text,+    database :: !DatabaseName,+    password :: !(Maybe Secret)+  }+  deriving stock (Eq)++instance Show AccessConfig where+  show AccessConfig {user, database} =+    "AccessConfig {user = " ++ show user ++ ", database = " ++ show database ++ ", password = <redacted>}"++-- | The only database-level privileges that the lifecycle layer may apply.+data DatabaseGrant = GrantConnect | GrantCreate | GrantTemporary+  deriving stock (Eq, Ord, Show)++data SchemaPrivilege = SchemaUsage | SchemaCreate+  deriving stock (Eq, Ord, Show)++data SchemaGrant = SchemaGrant+  { schema :: !SqlIdentifier,+    privilege :: !SchemaPrivilege+  }+  deriving stock (Eq, Show)++-- | A declared, non-secret database-level role setting. Its value is+-- configuration, never SQL text. Credential values belong in access targets.+data RoleSetting = RoleSetting+  { name :: !SqlIdentifier,+    value :: !Text+  }+  deriving stock (Eq, Show)++data CloneStrategy = WalLog | FileCopy+  deriving stock (Eq, Ord, Show)++-- | All limits are local to one suite-scoped manager.+data HinagataConfig = HinagataConfig+  { endpoint :: !EndpointConfig,+    project :: !ProjectId,+    fixtureRoot :: !FilePath,+    bundleRoot :: !FilePath,+    maintenanceDatabase :: !DatabaseName,+    maintenanceSchema :: !SqlIdentifier,+    administration :: !AccessConfig,+    setup :: !AccessConfig,+    application :: !AccessConfig,+    setupGrants :: ![DatabaseGrant],+    applicationGrants :: ![DatabaseGrant],+    setupSchemaGrants :: ![SchemaGrant],+    applicationSchemaGrants :: ![SchemaGrant],+    setupSettings :: ![RoleSetting],+    applicationSettings :: ![RoleSetting],+    cloneStrategy :: !CloneStrategy,+    acquisitionDeadlineMs :: !Positive,+    setupDeadlineMs :: !Positive,+    setupWorkers :: !Positive,+    activeLeases :: !Positive,+    pendingRequests :: !Positive,+    chunkSize :: !Positive,+    sqlSizeLimit :: !Positive+  }+  deriving stock (Eq, Show)++-- | Recheck the target invariant when turning a resolved access description into+-- a libpq target. The resolver's user decoder already guarantees this succeeds.+accessTarget :: EndpointConfig -> AccessConfig -> Either Text ConnectionTarget+accessTarget EndpointConfig {host, port} AccessConfig {user, database, password} =+  mkConnectionTarget host port user database password++hinagataConfig :: Settei.Config HinagataConfig+hinagataConfig =+  HinagataConfig+    <$> endpointConfig+    <*> required "project.id" "Project identity used in owned database names" projectDecoder+    <*> required "fixture.root" "Absolute fixture source directory" directoryDecoder+    <*> required "bundle.root" "Absolute private bundle cache directory" directoryDecoder+    <*> required "maintenance.database" "Existing maintenance database" databaseDecoder+    <*> defaulted "maintenance.schema" "Maintenance catalog schema" identifierDecoder "maintenance-schema" "Use a dedicated catalog schema" (knownIdentifier "hinagata")+    <*> accessConfig "administration"+    <*> accessConfig "setup"+    <*> accessConfig "application"+    <*> defaulted "setup.grants" "Database grants for setup role" grantsDecoder "setup-grants" "Allow setup migration and fixture creation" [GrantConnect, GrantCreate, GrantTemporary]+    <*> defaulted "application.grants" "Database grants for application role" grantsDecoder "application-grants" "Application connects without schema creation" [GrantConnect]+    <*> defaulted "setup.schema_grants" "Schema grants for setup role" schemaGrantsDecoder "setup-schema-grants" "Permit migrations in public" [SchemaGrant (knownIdentifier "public") SchemaUsage, SchemaGrant (knownIdentifier "public") SchemaCreate]+    <*> defaulted "application.schema_grants" "Schema grants for application role" schemaGrantsDecoder "application-schema-grants" "Permit application access to public" [SchemaGrant (knownIdentifier "public") SchemaUsage]+    <*> defaulted "setup.settings" "Database-level setup role settings" settingsDecoder "setup-settings" "No extra role settings" []+    <*> defaulted "application.settings" "Database-level application role settings" settingsDecoder "application-settings" "No extra role settings" []+    <*> defaulted "clone.strategy" "PostgreSQL clone strategy" (Settei.enumDecoder [("WAL_LOG", WalLog), ("FILE_COPY", FileCopy)]) "clone-strategy" "WAL_LOG is the portable default" WalLog+    <*> positiveDefault "limits.acquisition_deadline_ms" "Acquisition deadline in milliseconds" 30000+    <*> positiveDefault "limits.setup_deadline_ms" "Setup deadline in milliseconds" 300000+    <*> positiveDefault "limits.setup_workers" "Manager-local concurrent setup workers" 2+    <*> positiveDefault "limits.active_leases" "Manager-local active leases" 8+    <*> positiveDefault "limits.pending_requests" "Manager-local queued acquisition requests" 32+    <*> positiveDefault "limits.chunk_size" "CSV transfer chunk bytes" 65536+    <*> positiveDefault "limits.sql_size_limit" "Maximum SQL step bytes" 1048576++endpointConfig :: Settei.Config EndpointConfig+endpointConfig =+  EndpointConfig+    <$> required "endpoint.host" "Absolute socket directory or TCP hostname" hostDecoder+    <*> required "endpoint.port" "PostgreSQL listener port" portDecoder++accessConfig :: Text -> Settei.Config AccessConfig+accessConfig prefix =+  AccessConfig+    <$> required (prefix <> ".user") "Explicit database role" userDecoder+    <*> required (prefix <> ".database") "Explicit database for this role" databaseDecoder+    <*> Settei.optional (Settei.secretSetting (key (prefix <> ".password")) "Optional role password" secretDecoder)++-- | Explicit bindings only. Invalid static names or key collisions are returned+-- to the caller and can be forced in startup tests before reading the environment.+hinagataEnvironmentBindings :: Either (NonEmpty Env.EnvError) Env.Bindings+hinagataEnvironmentBindings =+  Env.bindings+    [ Env.binding (Env.EnvName ("HINAGATA_" <> Text.toUpper (Text.replace "." "_" name))) (key name)+    | name <- settingNames+    ]++settingNames :: [Text]+settingNames =+  [ "endpoint.host",+    "endpoint.port",+    "project.id",+    "fixture.root",+    "bundle.root",+    "maintenance.database",+    "maintenance.schema",+    "administration.user",+    "administration.database",+    "administration.password",+    "setup.user",+    "setup.database",+    "setup.password",+    "application.user",+    "application.database",+    "application.password",+    "setup.grants",+    "application.grants",+    "setup.schema_grants",+    "application.schema_grants",+    "setup.settings",+    "application.settings",+    "clone.strategy",+    "limits.acquisition_deadline_ms",+    "limits.setup_deadline_ms",+    "limits.setup_workers",+    "limits.active_leases",+    "limits.pending_requests",+    "limits.chunk_size",+    "limits.sql_size_limit"+  ]++required :: Text -> Text -> Settei.Decoder a -> Settei.Config a+required name description valueDecoder = Settei.required (Settei.publicSetting (key name) description valueDecoder)++defaulted :: (Show a) => Text -> Text -> Settei.Decoder a -> Text -> Text -> a -> Settei.Config a+defaulted name description valueDecoder rule explanation value =+  Settei.withDefault+    (Settei.publicShowSetting (key name) description valueDecoder)+    (Settei.constantDefault (Settei.RuleName rule) explanation value)++positiveDefault :: Text -> Text -> Int -> Settei.Config Positive+positiveDefault name description value =+  defaulted name description (positiveDecoder name) (name <> "-default") "Manager-local operational default" (knownPositive value)++key :: Text -> Settei.Key+key name = case Settei.parseKey name of+  Right result -> result+  Left problem -> error ("Invalid static Hinagata setting key: " ++ show problem)++knownPositive :: Int -> Positive+knownPositive value = case mkPositive "built-in limit" value of+  Right result -> result+  Left problem -> error (Text.unpack problem)++knownIdentifier :: Text -> SqlIdentifier+knownIdentifier value = case mkSqlIdentifier value of+  Right result -> result+  Left problem -> error (Text.unpack problem)++hostDecoder :: Settei.Decoder Host+hostDecoder = Settei.parsedDecoder "an absolute socket directory or TCP hostname" $ \value ->+  if Text.isPrefixOf "/" value then socketDirectory value else tcpHost value++portDecoder :: Settei.Decoder Port+portDecoder = Settei.decoder $ \settingKey raw -> do+  value <- Settei.runDecoder Settei.boundedIntegralDecoder settingKey raw+  either (const (Left (Settei.decodeFailure settingKey "port between 1 and 65535"))) Right (mkPort value)++projectDecoder :: Settei.Decoder ProjectId+projectDecoder = Settei.parsedDecoder "a lowercase project ID" mkProjectId++databaseDecoder :: Settei.Decoder DatabaseName+databaseDecoder = Settei.parsedDecoder "a PostgreSQL database name of at most 63 UTF-8 bytes" mkDatabaseName++identifierDecoder :: Settei.Decoder SqlIdentifier+identifierDecoder = Settei.parsedDecoder "a PostgreSQL identifier of at most 63 UTF-8 bytes" mkSqlIdentifier++directoryDecoder :: Settei.Decoder FilePath+directoryDecoder = Settei.parsedDecoder "an absolute directory path without NUL" $ \value ->+  if not (Text.null value) && not (Text.any (== '\0') value) && isAbsolute (Text.unpack value)+    then Right (Text.unpack value)+    else Left "invalid directory"++userDecoder :: Settei.Decoder Text+userDecoder = Settei.parsedDecoder "a nonempty database role without NUL" $ \value ->+  if Text.null value || Text.any (== '\0') value then Left "invalid user" else Right value++secretDecoder :: Settei.Decoder Secret+secretDecoder = Settei.parsedDecoder "a password without NUL" mkSecret++positiveDecoder :: Text -> Settei.Decoder Positive+positiveDecoder name = Settei.decoder $ \settingKey raw -> do+  value <- Settei.runDecoder Settei.boundedIntegralDecoder settingKey raw+  either (const (Left (Settei.decodeFailure settingKey "a positive integer"))) Right (mkPositive name value)++grantsDecoder :: Settei.Decoder [DatabaseGrant]+grantsDecoder = Settei.decoder $ \settingKey raw ->+  case scalarList raw of+    Nothing -> Left (Settei.decodeFailure settingKey "a comma-separated or array grant list")+    Just names -> traverse (parseGrant settingKey) names++parseGrant :: Settei.Key -> Text -> Either Settei.DecodeFailure DatabaseGrant+parseGrant settingKey value = case Text.toUpper (Text.strip value) of+  "CONNECT" -> Right GrantConnect+  "CREATE" -> Right GrantCreate+  "TEMPORARY" -> Right GrantTemporary+  _ -> Left (Settei.decodeFailure settingKey "CONNECT, CREATE, or TEMPORARY grants")++schemaGrantsDecoder :: Settei.Decoder [SchemaGrant]+schemaGrantsDecoder = Settei.decoder $ \settingKey raw ->+  case scalarList raw of+    Nothing -> Left (Settei.decodeFailure settingKey "a comma-separated or array schema grant list")+    Just values -> traverse (parseSchemaGrant settingKey) values++parseSchemaGrant :: Settei.Key -> Text -> Either Settei.DecodeFailure SchemaGrant+parseSchemaGrant settingKey value =+  case Text.breakOn ":" value of+    (schemaName, assigned)+      | not (Text.null assigned),+        Right schema <- mkSqlIdentifier (Text.strip schemaName) ->+          case Text.toUpper (Text.strip (Text.drop 1 assigned)) of+            "USAGE" -> Right SchemaGrant {schema, privilege = SchemaUsage}+            "CREATE" -> Right SchemaGrant {schema, privilege = SchemaCreate}+            _ -> invalid+    _ -> invalid+  where+    invalid = Left (Settei.decodeFailure settingKey "SCHEMA:USAGE or SCHEMA:CREATE grant")++settingsDecoder :: Settei.Decoder [RoleSetting]+settingsDecoder = Settei.decoder $ \settingKey raw ->+  case scalarList raw of+    Nothing -> Left (Settei.decodeFailure settingKey "a comma-separated or array setting list")+    Just values -> traverse (parseRoleSetting settingKey) values++parseRoleSetting :: Settei.Key -> Text -> Either Settei.DecodeFailure RoleSetting+parseRoleSetting settingKey value =+  case Text.breakOn "=" value of+    (settingName, assigned)+      | not (Text.null assigned),+        Right name <- mkSqlIdentifier (Text.strip settingName),+        let settingValue = Text.strip (Text.drop 1 assigned),+        not (Text.any (== '\0') settingValue) ->+          Right RoleSetting {name, value = settingValue}+    _ -> Left (Settei.decodeFailure settingKey "NAME=VALUE database setting")++scalarList :: Settei.RawValue -> Maybe [Text]+scalarList (Settei.RawText value)+  | Text.null (Text.strip value) = Just []+  | otherwise = Just (Text.splitOn "," value)+scalarList (Settei.RawArray values) = traverse textValue values+  where+    textValue (Settei.RawText value) = Just value+    textValue _ = Nothing+scalarList _ = Nothing
+ src/Hinagata/Connection.hs view
@@ -0,0 +1,130 @@+module Hinagata.Connection+  ( Host,+    socketDirectory,+    tcpHost,+    hostText,+    Secret,+    secretText,+    mkSecret,+    ConnectionTarget,+    mkConnectionTarget,+    connectionHost,+    connectionPort,+    connectionUser,+    connectionDatabase,+    connectionPassword,+    ConnectionString,+    connectionStringText,+    renderConnectionString,+  )+where++import Data.Text qualified as Text+import Hinagata.Prelude+import Hinagata.Types+import System.FilePath (isAbsolute)++data Host = SocketDirectory !Text | TcpHost !Text+  deriving stock (Eq, Show)++hostText :: Host -> Text+hostText (SocketDirectory value) = value+hostText (TcpHost value) = value++socketDirectory :: Text -> Either Text Host+socketDirectory value+  | Text.null value = Left "socket directory is empty"+  | Text.any (== '\0') value = Left "socket directory contains NUL"+  | not (isAbsolute (Text.unpack value)) = Left "socket directory must be absolute"+  | otherwise = Right (SocketDirectory value)++tcpHost :: Text -> Either Text Host+tcpHost value+  | Text.null value = Left "TCP host is empty"+  | Text.any (== '\0') value = Left "TCP host contains NUL"+  | Text.any (== '/') value = Left "TCP host contains '/'"+  | otherwise = Right (TcpHost value)++newtype Secret = Secret Text+  deriving stock (Eq)++instance Show Secret where+  show _ = "<redacted>"++mkSecret :: Text -> Either Text Secret+mkSecret value+  | Text.any (== '\0') value = Left "credential contains NUL"+  | otherwise = Right (Secret value)++secretText :: Secret -> Text+secretText (Secret value) = value++data ConnectionTarget = ConnectionTarget+  { host :: !Host,+    port :: !Port,+    user :: !Text,+    database :: !DatabaseName,+    password :: !(Maybe Secret)+  }+  deriving stock (Eq)++instance Show ConnectionTarget where+  show ConnectionTarget {host, port, user, database} =+    "ConnectionTarget {host = "+      ++ show host+      ++ ", port = "+      ++ show port+      ++ ", user = "+      ++ show user+      ++ ", database = "+      ++ show database+      ++ ", password = <redacted>}"++mkConnectionTarget :: Host -> Port -> Text -> DatabaseName -> Maybe Secret -> Either Text ConnectionTarget+mkConnectionTarget host port user database password+  | Text.null user = Left "database user is empty"+  | Text.any (== '\0') user = Left "database user contains NUL"+  | otherwise = Right ConnectionTarget {host, port, user, database, password}++connectionHost :: ConnectionTarget -> Host+connectionHost ConnectionTarget {host} = host++connectionPort :: ConnectionTarget -> Port+connectionPort ConnectionTarget {port} = port++connectionUser :: ConnectionTarget -> Text+connectionUser ConnectionTarget {user} = user++connectionDatabase :: ConnectionTarget -> DatabaseName+connectionDatabase ConnectionTarget {database} = database++connectionPassword :: ConnectionTarget -> Maybe Secret+connectionPassword ConnectionTarget {password} = password++newtype ConnectionString = ConnectionString Text+  deriving stock (Eq)++instance Show ConnectionString where+  show _ = "<redacted connection string>"++connectionStringText :: ConnectionString -> Text+connectionStringText (ConnectionString value) = value++-- | Render explicit endpoint fields, clearing ambient hostaddr overrides.+-- Other libpq defaults (including authentication and TLS) remain available.+renderConnectionString :: ConnectionTarget -> ConnectionString+renderConnectionString ConnectionTarget {host, port, user, database, password} =+  ConnectionString $+    Text.intercalate " " $+      [ field "host" (hostText host),+        field "hostaddr" "",+        field "port" (Text.pack (show (portNumber port))),+        field "user" user,+        field "dbname" (databaseNameText database)+      ]+        ++ maybe [] (\credential -> [field "password" (secretText credential)]) password+  where+    field key value = key <> "='" <> Text.concatMap escape value <> "'"+    escape '\'' = "\\'"+    escape '\\' = "\\\\"+    escape character = Text.singleton character
+ src/Hinagata/Error.hs view
@@ -0,0 +1,20 @@+-- | Shared location and operation context for typed diagnostics.+-- Backend and command-line errors remain in their owning packages.+module Hinagata.Error+  ( DiagnosticContext (..),+    diagnosticContext,+  )+where++import Hinagata.Fixture.Types (FixtureName)+import Hinagata.Prelude++data DiagnosticContext = DiagnosticContext+  { operation :: !Text,+    fixture :: !(Maybe FixtureName),+    sourcePath :: !(Maybe FilePath)+  }+  deriving stock (Eq, Show)++diagnosticContext :: Text -> DiagnosticContext+diagnosticContext operation = DiagnosticContext {operation, fixture = Nothing, sourcePath = Nothing}
+ src/Hinagata/Fixture/Bundle.hs view
@@ -0,0 +1,403 @@+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
+ src/Hinagata/Fixture/Graph.hs view
@@ -0,0 +1,63 @@+module Hinagata.Fixture.Graph+  ( GraphError (..),+    resolveFixtures,+  )+where++import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Set (Set)+import Data.Set qualified as Set+import Hinagata.Fixture.Types+import Hinagata.Prelude ()++data GraphError+  = DuplicateFixture !FixtureName+  | MissingFixture !FixtureName ![FixtureName]+  | FixtureCycle ![FixtureName]+  deriving stock (Eq, Show)++-- | Stable depth-first postorder: includes precede their consumer, each name+-- appears once, and requested roots keep caller order.+resolveFixtures :: [FixtureDefinition] -> [FixtureName] -> Either GraphError [FixtureDefinition]+resolveFixtures definitions roots = do+  definitionsByName <- buildIndex definitions+  (_, reverseOrder) <- visitMany definitionsByName [] Set.empty [] roots+  pure (reverse reverseOrder)++buildIndex :: [FixtureDefinition] -> Either GraphError (Map FixtureName FixtureDefinition)+buildIndex = foldl step (Right Map.empty)+  where+    step (Left failure) _ = Left failure+    step (Right definitionsByName) definition@FixtureDefinition {name} =+      if Map.member name definitionsByName+        then Left (DuplicateFixture name)+        else Right (Map.insert name definition definitionsByName)++visitMany ::+  Map FixtureName FixtureDefinition ->+  [FixtureName] ->+  Set FixtureName ->+  [FixtureDefinition] ->+  [FixtureName] ->+  Either GraphError (Set FixtureName, [FixtureDefinition])+visitMany _ _ visited output [] = Right (visited, output)+visitMany definitionsByName stack visited output (next : rest) = do+  (visited', output') <- visit definitionsByName stack visited output next+  visitMany definitionsByName stack visited' output' rest++visit ::+  Map FixtureName FixtureDefinition ->+  [FixtureName] ->+  Set FixtureName ->+  [FixtureDefinition] ->+  FixtureName ->+  Either GraphError (Set FixtureName, [FixtureDefinition])+visit definitionsByName stack visited output current+  | current `elem` stack = Left (FixtureCycle (dropWhile (/= current) (reverse stack) ++ [current]))+  | Set.member current visited = Right (visited, output)+  | otherwise = case Map.lookup current definitionsByName of+      Nothing -> Left (MissingFixture current (reverse stack ++ [current]))+      Just definition@FixtureDefinition {includes} -> do+        (visited', output') <- visitMany definitionsByName (current : stack) visited output includes+        pure (Set.insert current visited', definition : output')
+ src/Hinagata/Fixture/Manifest.hs view
@@ -0,0 +1,116 @@+module Hinagata.Fixture.Manifest+  ( ManifestError (..),+    FixtureManifest,+    decodeManifest,+    manifestDefinition,+    manifestDescription,+    implicitDefinition,+  )+where++import Data.Aeson (Value (..), (.:), (.:?))+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Aeson.Types (Parser, parseEither, withObject)+import Data.ByteString (ByteString)+import Data.Text qualified as Text+import Data.Yaml qualified as Yaml+import Hinagata.Fixture.Types+import Hinagata.Prelude+import Hinagata.Types+import System.FilePath (isAbsolute)++data ManifestError+  = InvalidYaml !Text+  | InvalidManifest !Text+  deriving stock (Eq, Show)++data FixtureManifest = FixtureManifest+  { name :: !FixtureName,+    description :: !(Maybe Text),+    includes :: ![FixtureName],+    steps :: ![FixtureStep]+  }+  deriving stock (Eq, Show, Generic)++-- | Decode an explicit fixture.yaml. Its name must match the directory name.+-- The caller still validates filesystem paths and captures bytes before use.+decodeManifest :: FixtureName -> ByteString -> Either ManifestError FixtureManifest+decodeManifest expected bytes = do+  value <- either (Left . InvalidYaml . Text.pack . show) Right (Yaml.decodeEither' bytes)+  parsed <- either (Left . InvalidManifest . Text.pack) Right (parseEither parseManifest value)+  let FixtureManifest {name = actualName} = parsed+  if actualName == expected+    then Right parsed+    else Left (InvalidManifest "manifest name does not match fixture directory")++manifestDefinition :: FixtureManifest -> FixtureDefinition+manifestDefinition FixtureManifest {name, includes, steps} = FixtureDefinition {name, includes, steps}++manifestDescription :: FixtureManifest -> Maybe Text+manifestDescription FixtureManifest {description} = description++-- | A directory without fixture.yaml has exactly one conventional SQL step.+implicitDefinition :: FixtureName -> FixtureDefinition+implicitDefinition name = FixtureDefinition {name, includes = [], steps = [SqlFile "fixture.sql"]}++parseManifest :: Value -> Parser FixtureManifest+parseManifest = withObject "fixture manifest" $ \object -> do+  rejectUnknown ["name", "description", "include", "steps"] object+  nameText <- object .: "name"+  name <- either (fail . Text.unpack) pure (mkFixtureName nameText)+  description <- object .:? "description"+  includeNames <- fromMaybe [] <$> object .:? "include"+  includes <- traverse (either (fail . Text.unpack) pure . mkFixtureName) includeNames+  rawSteps <- object .: "steps"+  steps <- case rawSteps of+    [] -> fail "steps must be non-empty"+    values -> traverse parseStep values+  pure FixtureManifest {name, description, includes, steps}++parseStep :: Value -> Parser FixtureStep+parseStep = withObject "fixture step" $ \object -> do+  rejectUnknown ["sql", "copy"] object+  case (KeyMap.lookup "sql" object, KeyMap.lookup "copy" object) of+    (Just (String path), Nothing) -> SqlFile <$> safePath path+    (Nothing, Just value) -> CopyCsv <$> parseCopy value+    _ -> fail "step must contain exactly one of sql or copy"++parseCopy :: Value -> Parser CopySpec+parseCopy = withObject "copy step" $ \object -> do+  rejectUnknown ["table", "columns", "file", "format", "header"] object+  tableValue <- object .: "table"+  (schema, table) <- parseTable tableValue+  columnNames <- object .: "columns"+  columns <- case columnNames of+    [] -> fail "COPY columns must be non-empty"+    values -> traverse identifier values+  fileText <- object .: "file"+  file <- safePath fileText+  format <- object .: "format"+  unless (format == ("csv" :: Text)) (fail "COPY format must be csv")+  header <- object .: "header"+  pure CopySpec {schema, table, columns, file, header}++parseTable :: Value -> Parser (SqlIdentifier, SqlIdentifier)+parseTable = withObject "COPY table" $ \object -> do+  rejectUnknown ["schema", "name"] object+  schema <- object .: "schema" >>= identifier+  table <- object .: "name" >>= identifier+  pure (schema, table)++identifier :: Text -> Parser SqlIdentifier+identifier = either (fail . Text.unpack) pure . mkSqlIdentifier++safePath :: Text -> Parser FilePath+safePath raw = do+  let path = Text.unpack raw+  when (Text.null raw || Text.any (== '\0') raw || isAbsolute path || any (`elem` [".", "..", ""]) (Text.splitOn "/" raw)) $+    fail "step path must be a non-empty relative path without . or .. components"+  pure path++rejectUnknown :: [Text] -> KeyMap.KeyMap Value -> Parser ()+rejectUnknown allowed object =+  case filter (`notElem` allowed) (map Key.toText (KeyMap.keys object)) of+    [] -> pure ()+    unknown -> fail ("unknown fields: " ++ Text.unpack (Text.intercalate ", " unknown))
+ src/Hinagata/Fixture/SqlPolicy.hs view
@@ -0,0 +1,128 @@+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)
+ src/Hinagata/Fixture/Types.hs view
@@ -0,0 +1,53 @@+module Hinagata.Fixture.Types+  ( FixtureName,+    fixtureNameText,+    mkFixtureName,+    FixtureDefinition (..),+    FixtureStep (..),+    CopySpec (..),+  )+where++import Data.Text qualified as Text+import Hinagata.Prelude+import Hinagata.Types (SqlIdentifier)++-- | A path component and stable fixture identity, not an arbitrary file path.+newtype FixtureName = FixtureName Text+  deriving stock (Eq, Ord, Show)++fixtureNameText :: FixtureName -> Text+fixtureNameText (FixtureName value) = value++mkFixtureName :: Text -> Either Text FixtureName+mkFixtureName value+  | Text.null value = Left "fixture name is empty"+  | Text.length value > 63 = Left "fixture name exceeds 63 characters"+  | Text.head value == '-' || Text.last value == '-' = Left "fixture name cannot start or end with '-':"+  | Text.all valid value = Right (FixtureName value)+  | otherwise = Left "fixture name must contain only lowercase ASCII letters, digits, and '-':"+  where+    valid character = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character == '-'++-- | Steps retain declaration order. Paths are validated and captured by the+-- bundle compiler before a plan becomes executable.+data FixtureStep+  = SqlFile !FilePath+  | CopyCsv !CopySpec+  deriving stock (Eq, Show, Generic)++data CopySpec = CopySpec+  { schema :: !SqlIdentifier,+    table :: !SqlIdentifier,+    columns :: ![SqlIdentifier],+    file :: !FilePath,+    header :: !Bool+  }+  deriving stock (Eq, Show, Generic)++data FixtureDefinition = FixtureDefinition+  { name :: !FixtureName,+    includes :: ![FixtureName],+    steps :: ![FixtureStep]+  }+  deriving stock (Eq, Show, Generic)
+ src/Hinagata/Hook/Revision.hs view
@@ -0,0 +1,152 @@+-- | Explicit, versioned identities for service-owned hook inputs. Locations and+-- literal values deliberately have no diagnostic rendering.+module Hinagata.Hook.Revision+  ( InputName,+    mkInputName,+    inputNameText,+    RevisionInput (..),+    RevisionError (..),+    CapturedRevision,+    hookRevision,+    revisionInputLabels,+    validateRevisionInputs,+    deriveHookRevision,+    verifyHookRevisionInputs,+  )+where++import Control.Exception (IOException, bracket, bracketOnError, try)+import Control.Monad (unless)+import Crypto.Hash.SHA256 qualified as SHA256+import Data.ByteString qualified as Bytes+import Data.ByteString.Builder (Builder, byteString, toLazyByteString, word64BE, word8)+import Data.List (sortOn)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Encoding+import Data.Word (Word64)+import Numeric (showHex)+import System.Directory (makeAbsolute)+import System.IO (hClose)+import System.Posix.Files (getFdStatus, isRegularFile)+import System.Posix.IO (OpenFileFlags (..), OpenMode (ReadOnly), closeFd, defaultFileFlags, fdToHandle, openFd)++newtype InputName = InputName Text+  deriving stock (Eq, Ord, Show)++inputNameText :: InputName -> Text+inputNameText (InputName name) = name++mkInputName :: Text -> Either RevisionError InputName+mkInputName name+  | Just (first, _) <- Text.uncons name,+    lower first,+    Text.all allowed name,+    all (\part -> part /= "" && part /= "." && part /= "..") (Text.splitOn "/" name) =+      Right (InputName name)+  | otherwise = Left InvalidInputName+  where+    lower c = c >= 'a' && c <= 'z'+    allowed c = lower c || (c >= '0' && c <= '9') || c `elem` ("._-/" :: String)++data RevisionInput+  = FileInput !InputName !FilePath+  | ValueInput !InputName !Text++data RevisionError+  = InvalidInputName+  | EmptyInputs+  | DuplicateInput !InputName+  | InputUnreadable !InputName+  | InputsChanged !InputName+  deriving stock (Eq, Show)++data Evidence = FileEvidence !InputName !FilePath !Word64 !Bytes.ByteString | ValueEvidence !InputName !Text++data CapturedRevision = CapturedRevision !Text ![Evidence]++hookRevision :: CapturedRevision -> Text+hookRevision (CapturedRevision revision _) = revision++-- | Safe logical labels and kinds; never locations or selected values.+revisionInputLabels :: CapturedRevision -> [(InputName, Text)]+revisionInputLabels (CapturedRevision _ evidence) = map label evidence+  where+    label (FileEvidence name _ _ _) = (name, "file")+    label (ValueEvidence name _) = (name, "value")++inputName :: RevisionInput -> InputName+inputName (FileInput name _) = name+inputName (ValueInput name _) = name++validateRevisionInputs :: [RevisionInput] -> Either RevisionError ()+validateRevisionInputs [] = Left EmptyInputs+validateRevisionInputs inputs = go Set.empty inputs+  where+    go _ [] = Right ()+    go seen (input : rest)+      | Set.member name seen = Left (DuplicateInput name)+      | otherwise = go (Set.insert name seen) rest+      where+        name = inputName input++deriveHookRevision :: [RevisionInput] -> IO (Either RevisionError CapturedRevision)+deriveHookRevision inputs = case validateRevisionInputs inputs of+  Left failure -> pure (Left failure)+  Right () -> do+    captured <- traverse capture (sortOn (Encoding.encodeUtf8 . inputNameText . inputName) inputs)+    pure $ do+      evidence <- sequence captured+      let encoding = byteString "hinagata-hook-revision-v1\0" <> word64BE (fromIntegral (length evidence)) <> foldMap encode evidence+          digest = SHA256.hashlazy (toLazyByteString encoding)+          hex = Text.pack (concatMap (\byte -> let value = showHex byte "" in if length value == 1 then '0' : value else value) (Bytes.unpack digest))+      Right (CapturedRevision ("hinagata-hook-revision-v1:sha256:" <> hex) evidence)++capture :: RevisionInput -> IO (Either RevisionError Evidence)+capture (ValueInput name value) = pure (Right (ValueEvidence name value))+capture (FileInput name path) = do+  result <- try @IOException $ do+    unless (not (null path) && '\0' `notElem` path) (ioError (userError "invalid input location"))+    absolute <- makeAbsolute path+    (size, digest) <- hashFile absolute+    pure (FileEvidence name absolute size digest)+  pure (either (const (Left (InputUnreadable name))) Right result)++-- Open nonblocking, then inspect the opened descriptor, so a pipe (including a+-- symlink to one or a path replaced during open) cannot block preparation.+hashFile :: FilePath -> IO (Word64, Bytes.ByteString)+hashFile path = bracket acquire hClose (\handle -> loop handle 0 SHA256.init)+  where+    acquire = bracketOnError (openFd path ReadOnly defaultFileFlags {nonBlock = True}) closeFd $ \fd -> do+      status <- getFdStatus fd+      unless (isRegularFile status) (ioError (userError "input is not regular"))+      fdToHandle fd+    loop handle count context = do+      bytes <- Bytes.hGet handle 65536+      if Bytes.null bytes+        then pure (count, SHA256.finalize context)+        else do+          let next = SHA256.update context bytes+              total = count + fromIntegral (Bytes.length bytes)+          next `seq` total `seq` loop handle total next++encode :: Evidence -> Builder+encode (FileEvidence name _ size digest) = sized (Encoding.encodeUtf8 (inputNameText name)) <> word8 1 <> word64BE size <> byteString digest+encode (ValueEvidence name value) = sized (Encoding.encodeUtf8 (inputNameText name)) <> word8 2 <> sized (Encoding.encodeUtf8 value)++sized :: Bytes.ByteString -> Builder+sized bytes = word64BE (fromIntegral (Bytes.length bytes)) <> byteString bytes++-- | Re-resolve every selected file, including symlinks, and compare its bytes.+-- This checks boundaries; callers must keep inputs stable throughout execution.+verifyHookRevisionInputs :: CapturedRevision -> IO (Either RevisionError ())+verifyHookRevisionInputs (CapturedRevision _ evidence) = go evidence+  where+    go [] = pure (Right ())+    go (ValueEvidence {} : rest) = go rest+    go (FileEvidence name path size digest : rest) = do+      result <- try @IOException (hashFile path)+      case result of+        Right current | current == (size, digest) -> go rest+        _ -> pure (Left (InputsChanged name))
+ src/Hinagata/Prelude.hs view
@@ -0,0 +1,24 @@+{-# LANGUAGE PackageImports #-}++module Hinagata.Prelude+  ( module X,+    module Control.Lens,+    wireOptions,+  )+where++import "aeson" Data.Aeson as X (FromJSON (..), Options, ToJSON (..), Value, defaultOptions)+import "aeson" Data.Aeson.Types as X (camelTo2)+import "base" Control.Applicative as X ((<|>))+import "base" Control.Monad as X (unless, void, when)+import "base" Data.List.NonEmpty as X (NonEmpty (..))+import "base" Data.Maybe as X (fromMaybe, isJust, isNothing)+import "base" GHC.Generics as X (Generic)+import "lens" Control.Lens+import "text" Data.Text as X (Text)+import "time" Data.Time as X (UTCTime)++-- | One wire-format policy for non-opaque records. Validated identifiers and+-- credential-bearing values receive hand-written instances instead.+wireOptions :: Options+wireOptions = defaultOptions
+ src/Hinagata/Types.hs view
@@ -0,0 +1,104 @@+module Hinagata.Types+  ( DatabaseName,+    databaseNameText,+    mkDatabaseName,+    ProjectId,+    projectIdText,+    mkProjectId,+    RunId,+    runIdText,+    mkRunId,+    SqlIdentifier,+    sqlIdentifierText,+    mkSqlIdentifier,+    quoteSqlIdentifier,+    Port,+    portNumber,+    mkPort,+    Positive,+    positiveValue,+    mkPositive,+  )+where++import Data.ByteString qualified as ByteString+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Encoding+import Hinagata.Prelude++newtype DatabaseName = DatabaseName Text+  deriving stock (Eq, Ord, Show)++databaseNameText :: DatabaseName -> Text+databaseNameText (DatabaseName value) = value++mkDatabaseName :: Text -> Either Text DatabaseName+mkDatabaseName value = DatabaseName <$> validatePgIdentifier value++newtype SqlIdentifier = SqlIdentifier Text+  deriving stock (Eq, Ord, Show)++sqlIdentifierText :: SqlIdentifier -> Text+sqlIdentifierText (SqlIdentifier value) = value++mkSqlIdentifier :: Text -> Either Text SqlIdentifier+mkSqlIdentifier value = SqlIdentifier <$> validatePgIdentifier value++quoteSqlIdentifier :: SqlIdentifier -> Text+quoteSqlIdentifier (SqlIdentifier value) = "\"" <> Text.replace "\"" "\"\"" value <> "\""++validatePgIdentifier :: Text -> Either Text Text+validatePgIdentifier value+  | Text.null value = Left "PostgreSQL identifier is empty"+  | Text.any (== '\0') value = Left "PostgreSQL identifier contains NUL"+  | ByteString.length (Encoding.encodeUtf8 value) > 63 = Left "PostgreSQL identifier exceeds 63 UTF-8 bytes"+  | otherwise = Right value++newtype ProjectId = ProjectId Text+  deriving stock (Eq, Ord, Show)++projectIdText :: ProjectId -> Text+projectIdText (ProjectId value) = value++mkProjectId :: Text -> Either Text ProjectId+mkProjectId value = ProjectId <$> validateSlug "project ID" value++newtype RunId = RunId Text+  deriving stock (Eq, Ord, Show)++runIdText :: RunId -> Text+runIdText (RunId value) = value++mkRunId :: Text -> Either Text RunId+mkRunId value = RunId <$> validateSlug "run ID" value++validateSlug :: Text -> Text -> Either Text Text+validateSlug label value+  | Text.null value = Left (label <> " is empty")+  | Text.length value > 63 = Left (label <> " exceeds 63 characters")+  | Text.all valid value = Right value+  | otherwise = Left (label <> " must use lowercase ASCII letters, digits, '-' or '_'")+  where+    valid character = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character == '-' || character == '_'++newtype Port = Port Int+  deriving stock (Eq, Ord, Show)++portNumber :: Port -> Int+portNumber (Port value) = value++mkPort :: Int -> Either Text Port+mkPort value+  | value >= 1 && value <= 65535 = Right (Port value)+  | otherwise = Left "port must be between 1 and 65535"++newtype Positive = Positive Int+  deriving stock (Eq, Ord, Show)++positiveValue :: Positive -> Int+positiveValue (Positive value) = value++mkPositive :: Text -> Int -> Either Text Positive+mkPositive label value+  | value > 0 = Right (Positive value)+  | otherwise = Left (label <> " must be positive")
+ test/Main.hs view
@@ -0,0 +1,404 @@+module Main (main) where++import Control.Monad (replicateM_)+import Data.ByteString.Char8 qualified as ByteString+import Data.List (isInfixOf, sort)+import Data.Text qualified as Text+import Hinagata.Config (AccessConfig (..), CloneStrategy (..), HinagataConfig (..), SchemaGrant (..), SchemaPrivilege (..), hinagataConfig, hinagataEnvironmentBindings)+import Hinagata.Connection+import Hinagata.Fixture.Bundle qualified as Bundle+import Hinagata.Fixture.Graph+import Hinagata.Fixture.Manifest+import Hinagata.Fixture.SqlPolicy+import Hinagata.Fixture.Types+import Hinagata.Hook.Revision qualified as Revision+import Hinagata.Prelude+import Hinagata.Types+import Settei qualified+import Settei.Env qualified as Env+import System.Directory (createDirectoryIfMissing, createFileLink, getModificationTime, removeFile, setModificationTime)+import System.Environment (getArgs)+import System.Exit (exitFailure)+import System.FilePath ((</>))+import System.IO (IOMode (WriteMode), withBinaryFile)+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Files (createNamedPipe, setFileMode)+import System.Posix.User (getEffectiveUserID)++main :: IO ()+main = do+  args <- getArgs+  case args of+    ["--csv-memory-probe", size, workspace] -> csvMemoryProbe (read size) workspace+    _ -> regressionTests++regressionTests :: IO ()+regressionTests = do+  let a = fixture "a" ["b", "c"]+      b = fixture "b" ["d"]+      c = fixture "c" ["d"]+      d = fixture "d" []+  assert "diamond closure" (fmap (map name) (resolveFixtures [a, b, c, d] [fixtureName "a"]) == Right (map fixtureName ["d", "b", "c", "a"]))+  assert "stable root order" (fmap (map name) (resolveFixtures [a, b, c, d] (map fixtureName ["c", "a"])) == Right (map fixtureName ["d", "c", "b", "a"]))+  assert "cycle path" (resolveFixtures [fixture "a" ["b"], fixture "b" ["c"], fixture "c" ["a"]] [fixtureName "a"] == Left (FixtureCycle (map fixtureName ["a", "b", "c", "a"])))+  assert "missing dependency path" (resolveFixtures [fixture "a" ["b"]] [fixtureName "a"] == Left (MissingFixture (fixtureName "b") (map fixtureName ["a", "b"])))+  assert "duplicate definition" (resolveFixtures [a, a] [fixtureName "a"] == Left (DuplicateFixture (fixtureName "a")))+  assert "Unicode byte limit" (either (const True) (const False) (mkDatabaseName (Text.replicate 32 "é")))+  assert "quoted SQL identifier" (fmap quoteSqlIdentifier (mkSqlIdentifier "x\"y") == Right "\"x\"\"y\"")+  assert "zero port rejected" (either (const True) (const False) (mkPort 0))+  assert "socket must be absolute" (either (const True) (const False) (socketDirectory "relative"))+  let target = do+        host <- socketDirectory "/tmp/a b'c\\d"+        port <- mkPort 5432+        database <- mkDatabaseName "example"+        password <- mkSecret "p'\\secret"+        mkConnectionTarget host port "service" database (Just password)+  assert "keyword escaping" (fmap (connectionStringText . renderConnectionString) target == Right "host='/tmp/a b\\'c\\\\d' hostaddr='' port='5432' user='service' dbname='example' password='p\\'\\\\secret'")+  assert "connection display redacts password" (all (not . (`isInfixOf` show target)) ["p'", "secret"])+  let validManifest =+        ByteString.pack+          ( unlines+              [ "name: example",+                "include: [common]",+                "steps:",+                "  - sql: prepare.sql",+                "  - copy:",+                "      table: {schema: app, name: members}",+                "      columns: [id, email]",+                "      file: members.csv",+                "      format: csv",+                "      header: true"+              ]+          )+  assert+    "manifest ordering"+    ( fmap (steps . manifestDefinition) (decodeManifest (fixtureName "example") validManifest)+        == Right+          [ SqlFile "prepare.sql",+            CopyCsv+              CopySpec+                { schema = sqlIdentifier "app",+                  table = sqlIdentifier "members",+                  columns = map sqlIdentifier ["id", "email"],+                  file = "members.csv",+                  header = True+                }+          ]+    )+  assert "unknown manifest fields rejected" (isManifestError (decodeManifest (fixtureName "example") (ByteString.pack "name: example\nunknown: true\nsteps: [{sql: fixture.sql}]\n")))+  assert "empty explicit steps rejected" (isManifestError (decodeManifest (fixtureName "example") (ByteString.pack "name: example\nsteps: []\n")))+  assert "directory/name mismatch rejected" (isManifestError (decodeManifest (fixtureName "other") validManifest))+  assert "path traversal rejected" (isManifestError (decodeManifest (fixtureName "example") (ByteString.pack "name: example\nsteps: [{sql: ../outside.sql}]\n")))+  assert "transaction control rejected" (checkSqlPolicy (ByteString.pack "/* comment */ BEGIN; SELECT 1") == Left (ForbiddenStatement "begin"))+  assert "nested comment does not conceal command" (checkSqlPolicy (ByteString.pack "/* outer /* nested */ end */ COPY x FROM STDIN") == Left (ForbiddenStatement "copy"))+  assert "dollar function body is opaque" (checkSqlPolicy (ByteString.pack "CREATE FUNCTION f() RETURNS void AS $$ BEGIN; COPY x FROM STDIN; END; $$ LANGUAGE plpgsql;") == Right ())+  assert "escape string is opaque" (checkSqlPolicy (ByteString.pack "SELECT E'abc\\'; COPY x FROM STDIN';") == Right ())+  assert "psql command rejected" (checkSqlPolicy (ByteString.pack "  \\i fixture.sql") == Left PsqlCommand)+  assert "NUL SQL rejected before libpq truncation" (checkSqlPolicy (ByteString.pack "SELECT 1;\0DELETE FROM users") == Left SqlContainsNul)+  assert "PREPARE TRANSACTION rejected" (checkSqlPolicy (ByteString.pack "PREPARE TRANSACTION 'x'") == Left (ForbiddenStatement "TRANSACTION"))+  assert "SQL PREPARE remains allowed" (checkSqlPolicy (ByteString.pack "PREPARE q AS SELECT 1") == Right ())+  assert+    "all top-level transaction forms rejected"+    ( all+        (isSqlPolicyError . checkSqlPolicy . ByteString.pack)+        ["START TRANSACTION", "COMMIT", "END", "ROLLBACK", "ABORT", "SAVEPOINT x", "RELEASE SAVEPOINT x"]+    )+  assert "standard string and quoted identifier are opaque" (checkSqlPolicy (ByteString.pack "SELECT 'BEGIN; COPY', \"COMMIT;\" FROM x;") == Right ())+  let whitespace = [" ", "\t", "\n", "\r", "\f", "\v"]+      forbidden = ["BEGIN", "COMMIT", "END", "ROLLBACK", "ABORT", "SAVEPOINT x", "RELEASE SAVEPOINT x", "COPY x FROM STDIN"]+  assert+    "all PostgreSQL ASCII whitespace preserves statement starts"+    ( all+        (isSqlPolicyError . checkSqlPolicy . ByteString.pack)+        [prefix ++ gap ++ statement | gap <- whitespace, statement <- forbidden, prefix <- ["", "SELECT 1;", "/* comment */", "-- comment\n"]]+    )+  assert+    "all PostgreSQL whitespace preserves transaction prefixes"+    ( all+        (isSqlPolicyError . checkSqlPolicy . ByteString.pack)+        [prefix ++ gap ++ "TRANSACTION 'x'" | gap <- whitespace, prefix <- ["START", "PREPARE"]]+    )+  assert+    "whitespace before psql commands preserves line start"+    (all ((== Left PsqlCommand) . checkSqlPolicy . ByteString.pack . (++ "\\i fixture.sql")) whitespace)+  assert+    "whitespace and forbidden words remain opaque inside literals and comments"+    ( all+        ((== Right ()) . checkSqlPolicy . ByteString.pack)+        ["SELECT '" ++ gap ++ "COMMIT;'; SELECT E'" ++ gap ++ "ROLLBACK;'; SELECT \"" ++ gap ++ "COPY\"; SELECT $$" ++ gap ++ "BEGIN;$$; /*" ++ gap ++ "COMMIT;*/ SELECT 1; --ROLLBACK" ++ gap ++ "\n" | gap <- whitespace]+    )+  configTests+  bundleTests+  revisionTests++-- | Run this in a fresh process with +RTS -s. The fixture data is generated+-- and captured in fixed chunks so maximum live residency can be compared for+-- 1x and 10x CSV inputs without materializing the CSV in this test process.+csvMemoryProbe :: Int -> FilePath -> IO ()+csvMemoryProbe byteCount workspace = do+  let fixtureRoot = workspace </> "fixtures"+      fixtureDirectory = fixtureRoot </> "bulk"+      bundleRoot = workspace </> "bundles"+      chunk = ByteString.replicate 65536 'A'+  createDirectoryIfMissing True fixtureDirectory+  ByteString.writeFile (fixtureDirectory </> "fixture.yaml") "name: bulk\nsteps:\n  - copy:\n      table: {schema: public, name: bulk}\n      columns: [payload]\n      file: bulk.csv\n      format: csv\n      header: false\n"+  withBinaryFile (fixtureDirectory </> "bulk.csv") WriteMode $ \handle ->+    replicateM_ (byteCount `div` ByteString.length chunk) (ByteString.hPut handle chunk)+  let config = Bundle.BundleConfig fixtureRoot bundleRoot (positive 1024) (positive 65536)+  result <- Bundle.compileFixtures config [fixtureName "bulk"]+  case result of+    Right plan -> putStrLn (Text.unpack (Bundle.planDigest plan))+    Left problem -> fail (show problem)++configTests :: IO ()+configTests = do+  validated <- case hinagataEnvironmentBindings of+    Right result -> pure result+    Left errors -> fail (show errors)+  assert "explicit environment binding count" (length (Env.bindingsList validated) == 30)+  assert "every declared setting has an environment binding" (sort (map Settei.schemaSettingKey (Settei.schemaPossible (Settei.describe hinagataConfig))) == sort (map Env.bindingKey (Env.bindingsList validated)))+  let low =+        sourceOrFail+          "config"+          Settei.BuiltInSource+          [ ("endpoint.host", "/tmp/postgresql"),+            ("endpoint.port", "5432"),+            ("project.id", "example"),+            ("fixture.root", "/tmp/fixtures"),+            ("bundle.root", "/tmp/bundles"),+            ("maintenance.database", "postgres"),+            ("administration.user", "admin"),+            ("administration.database", "postgres"),+            ("setup.user", "migrator"),+            ("setup.database", "template"),+            ("application.user", "service"),+            ("application.database", "testdb")+          ]+      env =+        Env.environmentSource+          validated+          ( Env.envSnapshot+              [ ("HINAGATA_APPLICATION_USER", "app_override"),+                ("HINAGATA_APPLICATION_PASSWORD", "top-secret-credential"),+                ("HINAGATA_CLONE_STRATEGY", "FILE_COPY")+              ]+          )+      resolved = Settei.resolve Settei.defaultResolveOptions [low, env] hinagataConfig+  case Settei.answer resolved of+    Left errors -> fail (Text.unpack (Settei.renderErrorsText errors))+    Right config -> do+      assert "later Settei source wins" (user (application config) == "app_override")+      assert "clone strategy supplied explicitly" (cloneStrategy config == FileCopy)+      assert "admin and app roles stay separate" (user (administration config) == "admin")+      assert "setup has explicit public schema CREATE" (any (\grant -> privilege grant == SchemaCreate) (setupSchemaGrants config))+      assert "visible WAL_LOG default overridden" (Text.isInfixOf "FILE_COPY" (Settei.renderResolutionText (Settei.report resolved)))+      assert "secret redacted on success" (not (Text.isInfixOf "top-secret-credential" (Settei.renderResolutionText (Settei.report resolved))))+      assert "secret redacted in typed config" (not (isInfixOf "top-secret-credential" (show config)))+  let bad = Env.environmentSource validated (Env.envSnapshot [("HINAGATA_APPLICATION_PASSWORD", "bad\0password"), ("HINAGATA_ENDPOINT_PORT", "0")])+      failed = Settei.resolve Settei.defaultResolveOptions [low, bad] hinagataConfig+      errorText = either Settei.renderErrorsText (const "") (Settei.answer failed)+  assert "bad settings rejected" (either (const True) (const False) (Settei.answer failed))+  assert "secret redacted on failure" (not (Text.isInfixOf "bad\0password" (errorText <> Settei.renderResolutionText (Settei.report failed))))+  let defaults = Settei.resolve Settei.defaultResolveOptions [low] hinagataConfig+  assert "default strategy visible" (Text.isInfixOf "clone.strategy = WalLog" (Settei.renderResolutionText (Settei.report defaults)))+  assert "no database default permits mutation" (either (const True) (const False) (Settei.answer (Settei.resolve Settei.defaultResolveOptions [] hinagataConfig)))++sourceOrFail :: Text -> Settei.SourceKind -> [(Text, Text)] -> Settei.Source+sourceOrFail name kind values =+  case Settei.sourceFromPairs name kind [(settingKey, Settei.RawText value) | (rawKey, value) <- values, Right settingKey <- [Settei.parseKey rawKey]] of+    Right result -> result+    Left errors -> error (show errors)++bundleTests :: IO ()+bundleTests = withSystemTempDirectory "hinagata-core-test-" $ \workspace -> do+  let fixtureRoot = workspace </> "fixtures"+      bundleRoot = workspace </> "bundles"+      fixtureDirectory = fixtureRoot </> "example"+      sqlPath = fixtureDirectory </> "fixture.sql"+      config =+        Bundle.BundleConfig+          { Bundle.fixtureRoot = fixtureRoot,+            Bundle.bundleRoot = bundleRoot,+            Bundle.sqlSizeLimit = positive 1024,+            Bundle.chunkSize = positive 7+          }+  createDirectoryIfMissing True fixtureDirectory+  ByteString.writeFile sqlPath "SELECT 1;"+  first <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "example"]+  again <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "example"]+  assert "bundle digest deterministic" (Bundle.planDigest first == Bundle.planDigest again)+  assert "verified bundle reused" (Bundle.planDirectory first == Bundle.planDirectory again)+  let capturedPath = Bundle.planDirectory first </> "step-0"+  assert "bundle contains captured bytes" =<< ((== "SELECT 1;") <$> ByteString.readFile capturedPath)+  ByteString.writeFile capturedPath "corrupted"+  rebuilt <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "example"]+  assert "modified cache rebuilt" (Bundle.planDigest first == Bundle.planDigest rebuilt && Bundle.planDirectory first /= Bundle.planDirectory rebuilt)+  ByteString.writeFile sqlPath "SELECT 2;"+  changed <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "example"]+  assert "source bytes change digest" (Bundle.planDigest changed /= Bundle.planDigest first)+  assert "existing plan retains frozen bytes" =<< ((== "SELECT 1;") <$> ByteString.readFile (Bundle.planDirectory rebuilt </> "step-0"))+  ByteString.writeFile sqlPath "BEGIN;"+  invalidSql <- Bundle.compileFixtures config [fixtureName "example"]+  assert "invalid SQL rejected before plan publication" (case invalidSql of Left (Bundle.InvalidSql _ _) -> True; _ -> False)+  let outsideFile = workspace </> "outside.sql"+  ByteString.writeFile outsideFile "SELECT 3;"+  createFileLink outsideFile (fixtureDirectory </> "escape.sql")+  ByteString.writeFile (fixtureDirectory </> "fixture.yaml") "name: example\nsteps: [{sql: escape.sql}]\n"+  escaped <- Bundle.compileFixtures config [fixtureName "example"]+  assert "symlink escape rejected" (case escaped of Left (Bundle.UnsafePath _) -> True; _ -> False)+  let brokenDirectory = fixtureRoot </> "broken"+  createDirectoryIfMissing True brokenDirectory+  ByteString.writeFile (brokenDirectory </> "fixture.sql") "SELECT 1;"+  createFileLink (workspace </> "missing.yaml") (brokenDirectory </> "fixture.yaml")+  broken <- Bundle.compileFixtures config [fixtureName "broken"]+  assert "broken manifest symlink rejected" (case broken of Left (Bundle.UnsafePath _) -> True; _ -> False)+  let copyDirectory = fixtureRoot </> "members"+      copyManifest = copyDirectory </> "fixture.yaml"+      csvPath = copyDirectory </> "members.csv"+  createDirectoryIfMissing True copyDirectory+  ByteString.writeFile csvPath "id\n1\n"+  ByteString.writeFile copyManifest "name: members\nsteps:\n  - copy:\n      table: {schema: app, name: members}\n      columns: [id]\n      file: members.csv\n      format: csv\n      header: true\n"+  copyOne <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "members"]+  ByteString.writeFile copyManifest "name: members\nsteps:\n  - copy:\n      table: {schema: app, name: members}\n      columns: [id]\n      file: members.csv\n      format: csv\n      header: false\n"+  copyTwo <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "members"]+  assert "COPY option changes digest" (Bundle.planDigest copyOne /= Bundle.planDigest copyTwo)+  ByteString.writeFile csvPath "id\n2\n"+  copyThree <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "members"]+  assert "CSV bytes change digest" (Bundle.planDigest copyTwo /= Bundle.planDigest copyThree)+  let leftDirectory = fixtureRoot </> "left"+      rightDirectory = fixtureRoot </> "right"+      scenarioDirectory = fixtureRoot </> "scenario"+  mapM_ (createDirectoryIfMissing True) [leftDirectory, rightDirectory, scenarioDirectory]+  ByteString.writeFile (leftDirectory </> "fixture.sql") "SELECT 10;"+  ByteString.writeFile (rightDirectory </> "fixture.sql") "SELECT 20;"+  ByteString.writeFile (scenarioDirectory </> "fixture.sql") "SELECT 30;"+  ByteString.writeFile (scenarioDirectory </> "fixture.yaml") "name: scenario\ninclude: [left, right]\nsteps: [{sql: fixture.sql}]\n"+  orderOne <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "scenario"]+  ByteString.writeFile (scenarioDirectory </> "fixture.yaml") "name: scenario\ninclude: [right, left]\nsteps: [{sql: fixture.sql}]\n"+  orderTwo <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "scenario"]+  assert "include order changes digest" (Bundle.planDigest orderOne /= Bundle.planDigest orderTwo)+  base <- bundleOrFail =<< Bundle.compileFixtures config (map fixtureName ["members", "left", "right"])+  let composed = Bundle.composePlans base orderOne+  assert "base prefix retains unrelated fixtures" (case composed of Right result -> length (Bundle.basePrefix result) == 3 && length (Bundle.scenarioRemainder result) == 1; _ -> False)+  case composed of+    Left _ -> fail "expected composable plans"+    Right selected -> do+      assert "scenario remainder verifies" =<< Bundle.verifyComposedRemainder orderOne selected+      (sharedStep, suffixStep) <- case (Bundle.planFixtures orderOne, Bundle.scenarioRemainder selected) of+        (shared : _, suffix : _) -> case (Bundle.steps shared, Bundle.steps (Bundle.fixture suffix)) of+          (sharedStep : _, suffixStep : _) -> pure (sharedStep, suffixStep)+          _ -> fail "expected shared and suffix steps"+        _ -> fail "expected shared and suffix fixtures"+      let sharedPath = Bundle.planDirectory orderOne </> Bundle.relativePath sharedStep+          suffixPath = Bundle.planDirectory orderOne </> Bundle.relativePath suffixStep+      ByteString.writeFile sharedPath "corrupted shared bytes"+      assert "complete plan detects changed shared bytes" . not =<< Bundle.verifyPlan orderOne+      assert "clone skips already sealed shared bytes" =<< Bundle.verifyComposedRemainder orderOne selected+      ByteString.writeFile suffixPath "corrupted scenario bytes"+      assert "clone rejects changed scenario suffix" . not =<< Bundle.verifyComposedRemainder orderOne selected+  assert "COPY option conflict rejected" (Bundle.composePlans copyOne copyTwo == Left (Bundle.ConflictingFixture (fixtureName "members")))+  ByteString.writeFile (leftDirectory </> "fixture.sql") "SELECT 11;"+  changedShared <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "scenario"]+  assert "shared byte conflict rejected" (Bundle.composePlans base changedShared == Left (Bundle.ConflictingFixture (fixtureName "left")))+  ByteString.writeFile (leftDirectory </> "fixture.sql") "SELECT 10;"+  ByteString.writeFile (leftDirectory </> "fixture.yaml") "name: left\ninclude: [right]\nsteps: [{sql: fixture.sql}]\n"+  changedIncludes <- bundleOrFail =<< Bundle.compileFixtures config [fixtureName "scenario"]+  assert "shared dependency conflict rejected" (Bundle.composePlans base changedIncludes == Left (Bundle.ConflictingFixture (fixtureName "left")))++bundleOrFail :: Either Bundle.BundleError Bundle.FixturePlan -> IO Bundle.FixturePlan+bundleOrFail = either (fail . show) pure++positive :: Int -> Positive+positive value = either (error . show) id (mkPositive "test" value)++fixture :: Text -> [Text] -> FixtureDefinition+fixture fixtureNameValue includeNames =+  FixtureDefinition+    { name = fixtureName fixtureNameValue,+      includes = map fixtureName includeNames,+      steps = [SqlFile "fixture.sql"]+    }++fixtureName :: Text -> FixtureName+fixtureName value = either (error . show) id (mkFixtureName value)++assert :: String -> Bool -> IO ()+assert label success = unless success $ do+  putStrLn ("FAIL: " ++ label)+  exitFailure++sqlIdentifier :: Text -> SqlIdentifier+sqlIdentifier value = either (error . show) id (mkSqlIdentifier value)++isManifestError :: Either ManifestError a -> Bool+isManifestError (Left _) = True+isManifestError (Right _) = False++isSqlPolicyError :: Either SqlPolicyError () -> Bool+isSqlPolicyError (Left _) = True+isSqlPolicyError (Right ()) = False++-- Fixed vectors are independently calculated from the version-1 binary format.+revisionTests :: IO ()+revisionTests = withSystemTempDirectory "hook-revision" $ \root -> do+  let name = either (error . show) id . Revision.mkInputName+      file label path = Revision.FileInput (name label) path+      value label text = Revision.ValueInput (name label) text+      derive inputs = Revision.deriveHookRevision inputs >>= either (fail . show) pure+      digest inputs = Revision.hookRevision <$> derive inputs+      rejected label inputs = Revision.deriveHookRevision inputs >>= assert label . either (const True) (const False)+      a = root </> "a"+      b = root </> "b"+  createDirectoryIfMissing True a+  createDirectoryIfMissing True b+  ByteString.writeFile (a </> "sql") "abc"+  ByteString.writeFile (b </> "sql") "abc"+  captured <- derive [file "sql" (a </> "sql"), value "marker" "é=\n"]+  let original = Revision.hookRevision captured+  assert "revision known answer file and UTF8 value" (original == "hinagata-hook-revision-v1:sha256:a63a56ff32994b9b2808afb2ff6378008b60208554fa07208dce418c4e0d88af")+  emptyValue <- digest [value "value" ""]+  assert "revision known answer empty value" (emptyValue == "hinagata-hook-revision-v1:sha256:2c43685f19a86f7630b96a5899b145d85c55a6a2c00c285ddb45dcf107e7cea6")+  relocated <- digest [value "marker" "é=\n", file "sql" (b </> "sql")]+  assert "revision order and relocation independent" (original == relocated)+  before <- getModificationTime (a </> "sql")+  setModificationTime (a </> "sql") (read "2000-01-01 00:00:00 UTC")+  after <- getModificationTime (a </> "sql")+  same <- digest [file "sql" (a </> "sql"), value "marker" "é=\n"]+  assert "revision excludes timestamps" (before /= after && same == original)+  unchanged <- Revision.verifyHookRevisionInputs captured+  assert "revision verifies stable bytes" (unchanged == Right ())+  mapM_+    (\inputs -> digest inputs >>= assert "revision changes with identity" . (/= original))+    [ [file "renamed" (a </> "sql"), value "marker" "é=\n"],+      [file "sql" (a </> "sql"), value "marker" "é="],+      [value "sql" "abc", value "marker" "é=\n"]+    ]+  boundaryA <- digest [value "a" "b=c\nd"]+  boundaryB <- digest [value "a" "b=c", value "d" ""]+  assert "revision framing distinguishes delimiters" (boundaryA /= boundaryB)+  ByteString.writeFile (a </> "sql") "abd"+  changed <- digest [file "sql" (a </> "sql"), value "marker" "é=\n"]+  assert "revision hashes exact bytes" (changed /= original)+  Revision.verifyHookRevisionInputs captured >>= assert "revision detects changed content" . either (const True) (const False)+  rejected "revision requires inputs" []+  rejected "revision rejects duplicate kinds" [file "sql" (a </> "sql"), value "sql" "x"]+  rejected "revision rejects duplicate executable" [value "executable" "a", value "executable" "b"]+  mapM_+    (\label -> assert "revision invalid name" (Revision.mkInputName label == Left Revision.InvalidInputName))+    ["", "A", "a//b", "a/./b", "a/../b", "a/", "a=b", "a\nb", "é"]+  rejected "revision missing file" [file "missing" (root </> "private-missing-sentinel")]+  rejected "revision directory" [file "directory" a]+  createNamedPipe (root </> "pipe") 0o600+  rejected "revision refuses pipe without blocking" [file "pipe" (root </> "pipe")]+  createFileLink (a </> "sql") (root </> "link")+  linked <- derive [file "sql" (root </> "link")]+  ByteString.writeFile (a </> "sql") "abe"+  Revision.verifyHookRevisionInputs linked >>= assert "revision detects changed symlink target" . either (const True) (const False)+  removeFile (a </> "sql")+  Revision.verifyHookRevisionInputs linked >>= assert "revision detects deleted symlink target" . either (const True) (const False)+  rejected "revision dangling symlink" [file "sql" (root </> "link")]+  uid <- getEffectiveUserID+  assert "permission regression must run unprivileged" (uid /= 0)+  setFileMode (b </> "sql") 0o000+  rejected "revision unreadable file with real permission boundary" [file "sql" (b </> "sql")]+  setFileMode (b </> "sql") 0o600