packages feed

clickhouse-migrate-0.1.0.0: src/ClickHouseMigrate/Internal/LedgerValidation.hs

-- | Validated ledger observations and explicit administrative assertions.
-- Assertions document caller evidence; they do not independently prove fencing.
module ClickHouseMigrate.Internal.LedgerValidation
  ( AttemptId,
    parseAttemptId,
    attemptIdText,
    LedgerPosition,
    ledgerPositionText,
    parseLedgerPosition,
    AppendReceipt,
    appendReceiptText,
    parseAppendReceipt,
    appendQueryId,
    ExpectedLedgerState (ExpectAbsent, ExpectPosition),
    AuditContext,
    auditContext,
    auditActor,
    auditReason,
    auditReferences,
    QuiescenceEvidence,
    attestQuiescence,
    quiescenceTarget,
    InitializationAuthorization,
    authorizeInitialization,
    EvidenceLevel (OperatorAttestation, PredecessorChecksum),
    LedgerError (..),
  )
where

import ClickHouseMigrate.Internal.LedgerTypes
import ClickHouseMigrate.Prelude
import ClickHouseMigrate.Target qualified as Target
import Data.ByteString qualified as BS
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding
import Data.Word (Word64)
import Numeric (readHex, showHex)
import Text.Read (readMaybe)

ledgerPositionText :: LedgerPosition -> Text
ledgerPositionText (LedgerPosition identity sequenceNumber event) =
  "chm1:" <> Target.ledgerIdText identity <> ":" <> Text.pack (show sequenceNumber) <> ":" <> event

parseLedgerPosition :: Text -> Either LedgerError LedgerPosition
parseLedgerPosition encoded = case Text.splitOn ":" encoded of
  ["chm1", uuid, number, event] -> do
    identity <- either (const (Left InvalidPosition)) Right (Target.ledgerId uuid)
    _ <- either (const (Left InvalidPosition)) Right (Target.ledgerId event)
    sequenceNumber <- maybe (Left InvalidPosition) Right (readMaybe (Text.unpack number) :: Maybe Word64)
    let result = LedgerPosition identity sequenceNumber event
    if sequenceNumber > 0 && ledgerPositionText result == encoded then Right result else Left InvalidPosition
  _ -> Left InvalidPosition

auditContext :: Text -> Text -> [Text] -> Either LedgerError AuditContext
auditContext actor reason references
  | bounded 256 actor && bounded 4096 reason && length references <= 32 && all (bounded 2048) references = Right (AuditContext actor reason references)
  | otherwise = Left InvalidAudit
  where
    bounded limit value = not (Text.null (Text.strip value)) && Text.length value <= limit && not (Text.any (== '\0') value)

auditActor :: AuditContext -> Text
auditActor (AuditContext value _ _) = value

auditReason :: AuditContext -> Text
auditReason (AuditContext _ value _) = value

auditReferences :: AuditContext -> [Text]
auditReferences (AuditContext _ _ value) = value

-- | Caller attests that every predecessor has been fenced and ALL of its server
-- requests (including initialization DDL/ledger INSERTs) have ended. An empty
-- read, released OS lock, expired lease or absent process ID is insufficient.
attestQuiescence :: Target.TargetKey -> AuditContext -> Either LedgerError QuiescenceEvidence
attestQuiescence target audit@(AuditContext _ _ references)
  | null references = Left InvalidEvidence
  | otherwise = Right (QuiescenceEvidence target audit)

-- | A one-time target/UUID-bound first-use authorization. Persist this UUID with
-- an interrupted bootstrap; never generate a different one to resume it.
authorizeInitialization :: Target.TargetKey -> Target.LedgerId -> AuditContext -> InitializationAuthorization
authorizeInitialization = InitializationAuthorization

appendReceiptText :: AppendReceipt -> Text
appendReceiptText (AppendReceipt target ledger sequenceNumber event digest query) =
  Text.intercalate ":" ["chm-receipt1", hex (Encoding.encodeUtf8 (Target.targetKeyText target)), Target.ledgerIdText ledger, Text.pack (show sequenceNumber), event, digest, Target.queryIdText query]
  where
    hex = Text.pack . concatMap (\byte -> let value = showHex byte "" in replicate (2 - length value) '0' ++ value) . BS.unpack

parseAppendReceipt :: Text -> Either LedgerError AppendReceipt
parseAppendReceipt encoded = case Text.splitOn ":" encoded of
  ["chm-receipt1", targetHex, ledger, number, event, digest, query] | Text.length encoded <= 1024 -> do
    bytes <- unhex (Text.unpack targetHex)
    targetText <- either (const (Left InvalidPosition)) Right (Encoding.decodeUtf8' (BS.pack bytes))
    target <- either (const (Left InvalidPosition)) Right (Target.targetKey targetText)
    identity <- either (const (Left InvalidPosition)) Right (Target.ledgerId ledger)
    _ <- either (const (Left InvalidPosition)) Right (Target.ledgerId event)
    sequenceNumber <- maybe (Left InvalidPosition) Right (readMaybe (Text.unpack number) :: Maybe Word64)
    request <- either (const (Left InvalidPosition)) Right (Target.queryId query)
    let result = AppendReceipt target identity sequenceNumber event digest request
    if sequenceNumber > 0 && Text.length digest == 64 && Text.all (\c -> c >= '0' && c <= '9' || c >= 'a' && c <= 'f') digest && appendReceiptText result == encoded then Right result else Left InvalidPosition
  _ -> Left InvalidPosition
  where
    unhex [] = Right []
    unhex (a : b : rest) = case readHex [a, b] of [(byte, "")] -> (byte :) <$> unhex rest; _ -> Left InvalidPosition
    unhex _ = Left InvalidPosition

quiescenceTarget :: QuiescenceEvidence -> Target.TargetKey
quiescenceTarget (QuiescenceEvidence target _) = target

appendQueryId :: AppendReceipt -> Target.QueryId
appendQueryId (AppendReceipt _ _ _ _ _ query) = query

parseAttemptId :: Text -> Either LedgerError AttemptId
parseAttemptId value = either (const (Left InvalidPosition)) (const (Right (AttemptId value))) (Target.ledgerId value)

attemptIdText :: AttemptId -> Text
attemptIdText (AttemptId value) = value