packages feed

hinagata-core-0.1.0.0: src/Hinagata/Hook/Revision.hs

-- | 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))