packages feed

settei-preflight-0.3.0.0: src/Settei/Preflight/Request.hs

-- | Strict version-one request documents for deployment tooling.
-- Public values and fake stand-ins may be inline. Real secrets use references.
-- Parser diagnostics retain only schema paths and fixed messages, never input text.
module Settei.Preflight.Request
  ( PreflightRequest (..),
    RequestEntry (..),
    EntryForm (..),
    preflightRequest,
    parsePreflightRequest,
    renderPreflightRequestJson,
    RequestError,
    renderRequestErrorText,
    renderRequestErrorsText,
    RequestEnvironment (..),
    processRequestEnvironment,
    ResolvedRequest (..),
    resolveRequest,
  )
where

import Control.Monad (unless)
import Data.Aeson qualified as A
import Data.Aeson.Encoding qualified as E
import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString qualified as BS
import Data.Generics.Labels ()
import Data.List.NonEmpty qualified as NE
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Settei (Sensitivity (..))
import Settei.Env (EnvName (..))
import Settei.Preflight.Internal
import Settei.Prelude
import System.Environment qualified as Env

-- No Show on any request-bearing type: callers must not log unresolved/raw inputs.

-- | A version-one tooling request before secret-reference resolution. Inline values
-- must be public or fake stand-ins. No Show instance prevents accidental raw logging.
data PreflightRequest = PreflightRequest
  { role :: !RoleLabel,
    command :: !CheckCommand,
    environment :: ![RequestEntry],
    files :: ![RequestEntry],
    inherit :: !(Maybe [EnvName]),
    timeoutSeconds :: !Int,
    exitCodes :: !ExitCodeMapping,
    notes :: ![Text]
  }
  deriving stock (Generic, Eq)

-- | A proposed environment/file name with exactly one value form.
data RequestEntry = RequestEntry {name :: !Text, form :: !EntryForm} deriving stock (Generic, Eq)

-- | A public inline value, secret reference, fake synthetic value with sensitivity
-- and reason, unresolved input, or unavailable secret. Real secrets use references.
data EntryForm
  = InlineValue !Text
  | SecretFromEnvironment !EnvName
  | SecretFromFile !FilePath
  | SyntheticEntry !Sensitivity !Text !Text
  | UnresolvedEntry !Text
  | UnavailableSecretEntry !Text
  deriving stock (Generic, Eq)

-- | A safe schema path and fixed diagnostic, without input excerpts or values.
newtype RequestError = RequestError Text deriving stock (Generic, Eq, Show)

-- | Render one safe request error without raw parser details.
renderRequestErrorText :: RequestError -> Text
renderRequestErrorText (RequestError t) = t

-- | Render request errors in order, one per line.
renderRequestErrorsText :: NonEmpty RequestError -> Text
renderRequestErrorsText = T.unlines . fmap renderRequestErrorText . NE.toList

-- | Construct a request with a 60-second timeout, default exit codes, no notes,
-- and the default inheritance policy. The runner validates the assembled proposal.
preflightRequest :: RoleLabel -> CheckCommand -> [RequestEntry] -> [RequestEntry] -> PreflightRequest
preflightRequest r c es fs = PreflightRequest r c es fs Nothing 60 defaultExitCodes []

-- A deliberately small closed-schema parser. Aeson's syntax/type errors are never retained.
type Parsed a = Either RequestError a

bad :: Text -> Text -> Parsed a
bad path message = Left (RequestError (path <> ": " <> message))

objectAt :: Text -> [Text] -> A.Value -> Parsed A.Object
objectAt path allowed = \case
  A.Object o -> if all ((`elem` allowed) . K.toText) (KM.keys o) then Right o else bad path "unknown key"
  _ -> bad path "expected object"

required :: Text -> A.Object -> Text -> Parsed A.Value
required path o key = maybe (bad (path <> "." <> key) "required field is missing") Right (KM.lookup (K.fromText key) o)

textAt :: Text -> A.Value -> Parsed Text
textAt path = \case A.String t -> Right t; _ -> bad path "expected string"

intAt :: Text -> A.Value -> Parsed Int
intAt path v = case A.fromJSON v of A.Success n -> Right n; A.Error _ -> bad path "expected integer"

arrayAt :: Text -> (Text -> A.Value -> Parsed a) -> A.Value -> Parsed [a]
arrayAt path parse v = case A.fromJSON v of
  A.Success values -> sequence [parse (path <> "[" <> tshow i <> "]") value | (i, value) <- zip [0 :: Int ..] values]
  A.Error _ -> bad path "expected array"

optional :: Text -> A.Object -> Text -> a -> (Text -> A.Value -> Parsed a) -> Parsed a
optional path o key fallback parse = maybe (Right fallback) (parse (path <> "." <> key)) (KM.lookup (K.fromText key) o)

field :: Text -> A.Object -> Text -> (Text -> A.Value -> Parsed a) -> Parsed a
field path o key parse = required path o key >>= parse (path <> "." <> key)

-- | Parse strict settei.preflight-request schema-version-1 UTF-8 JSON. Reject
-- unknown keys, conflicting forms, and unsupported versions without source excerpts.
-- Structural proposal validation still occurs at the runner boundary.
parsePreflightRequest :: BS.ByteString -> Either (NonEmpty RequestError) PreflightRequest
parsePreflightRequest bytes = case A.eitherDecodeStrict bytes of
  Left _ -> Left (RequestError "request: invalid JSON" :| [])
  Right value -> case parseRequest value of Left e -> Left (e :| []); Right request -> Right request

parseRequest :: A.Value -> Parsed PreflightRequest
parseRequest value = do
  let path = "request"
  o <- objectAt path ["schemaVersion", "type", "role", "command", "environment", "files", "inherit", "timeoutSeconds", "exitCodes", "notes"] value
  version <- field path o "schemaVersion" intAt
  unless (version == 1) (bad "request.schemaVersion" "unsupported version")
  typ <- field path o "type" textAt
  unless (typ == "settei.preflight-request") (bad "request.type" "unexpected document type")
  label <- field path o "role" textAt
  unless (not (T.null (T.strip label))) (bad "request.role" "must not be empty")
  cmd <- field path o "command" parseCommand
  es <- field path o "environment" (\p -> arrayAt p parseEntry)
  fs <- optional path o "files" [] (\p -> arrayAt p parseEntry)
  inh <- case KM.lookup "inherit" o of Nothing -> Right Nothing; Just v -> Just <$> arrayAt "request.inherit" parseEnvName v
  seconds <- optional path o "timeoutSeconds" 60 intAt
  unless (seconds > 0 && seconds <= maxBound `div` 1000000) (bad "request.timeoutSeconds" "outside supported range")
  codes <- optional path o "exitCodes" defaultExitCodes parseCodes
  ns <- optional path o "notes" [] (\p -> arrayAt p textAt)
  pure (PreflightRequest (RoleLabel label) cmd es fs inh seconds codes ns)

parseEnvName :: Text -> A.Value -> Parsed EnvName
parseEnvName path v = do
  n <- EnvName <$> textAt path v
  if validEnvironmentName n then Right n else bad path "invalid environment variable name"

parseCommand :: Text -> A.Value -> Parsed CheckCommand
parseCommand path value = do
  o <- objectAt path ["executable", "arguments"] value
  exe <- field path o "executable" textAt
  args <- field path o "arguments" (\p -> arrayAt p parseArgument)
  pure (CheckCommand (T.unpack exe) args)

parseArgument :: Text -> A.Value -> Parsed Argument
parseArgument path value = do
  o <- objectAt path ["literal", "file", "directory", "parts"] value
  if KM.size o /= 1
    then bad path "expected exactly one argument form"
    else case KM.lookup "parts" o of
      Just v -> Argument <$> arrayAt (path <> ".parts") parsePart v
      Nothing -> Argument . pure <$> parsePart path value

parsePart :: Text -> A.Value -> Parsed ArgumentPart
parsePart path value = do
  o <- objectAt path ["literal", "file", "directory"] value
  if KM.size o /= 1
    then bad path "expected exactly one argument part"
    else case () of
      _
        | Just v <- KM.lookup "literal" o -> LiteralPart <$> textAt (path <> ".literal") v
        | Just v <- KM.lookup "file" o -> FilePart . FileName <$> textAt (path <> ".file") v
        | Just v <- KM.lookup "directory" o -> DirectoryPart . FileName <$> textAt (path <> ".directory") v
        | otherwise -> bad path "expected argument part"

parseEntry :: Text -> A.Value -> Parsed RequestEntry
parseEntry path value = do
  o <- objectAt path ["name", "value", "secretFromEnvironment", "secretFromFile", "synthetic", "reason", "sensitivity", "unresolved", "unavailableSecret"] value
  name <- field path o "name" textAt
  let forms = [k | k <- ["value", "secretFromEnvironment", "secretFromFile", "synthetic", "unresolved", "unavailableSecret"], KM.member k o]
  unless (length forms == 1) (bad path "expected exactly one value form")
  entry <- case forms of
    ["synthetic"] -> do
      standin <- field path o "synthetic" textAt
      reason <- field path o "reason" textAt
      sensitivity <- optional path o "sensitivity" Public (\p v -> textAt p v >>= \case "public" -> Right Public; "secret" -> Right Secret; _ -> bad p "expected public or secret")
      pure (SyntheticEntry sensitivity standin reason)
    [k] -> do
      unless (not (KM.member "reason" o || KM.member "sensitivity" o)) (bad path "reason and sensitivity require a synthetic entry; real secrets must use references")
      case k of
        "value" -> InlineValue <$> field path o "value" textAt
        "secretFromEnvironment" -> SecretFromEnvironment <$> field path o "secretFromEnvironment" parseEnvName
        "secretFromFile" -> SecretFromFile . T.unpack <$> field path o "secretFromFile" textAt
        "unresolved" -> UnresolvedEntry <$> field path o "unresolved" textAt
        _ -> UnavailableSecretEntry <$> field path o "unavailableSecret" textAt
    _ -> bad path "expected value form"
  pure (RequestEntry name entry)

parseCodes :: Text -> A.Value -> Parsed ExitCodeMapping
parseCodes path value = do
  o <- objectAt path ["accepted", "usage", "sourceFailure", "rejected"] value
  codes <- ExitCodeMapping <$> field path o "accepted" intAt <*> field path o "usage" (\p -> arrayAt p intAt) <*> field path o "sourceFailure" (\p -> arrayAt p intAt) <*> field path o "rejected" (\p -> arrayAt p intAt)
  if validExitCodes codes then Right codes else bad path "exit codes must be distinct integers in 0..255"

-- | Serialize a request deterministically without resolving secret references.
-- Inline public/fake values are retained, so this is not a redaction or logging API.
renderPreflightRequestJson :: PreflightRequest -> Text
renderPreflightRequestJson r =
  encodingText $
    obj $
      [("schemaVersion", json (1 :: Int)), ("type", json ("settei.preflight-request" :: Text)), ("role", json label), ("command", obj [("executable", json (r ^. #command . #executable)), ("arguments", E.list argumentJson (r ^. #command . #arguments))]), ("environment", E.list entryJson (r ^. #environment)), ("files", E.list entryJson (r ^. #files))]
        <> maybe [] (\names -> [("inherit", json (fmap envText names))]) (r ^. #inherit)
        <> [("timeoutSeconds", json (r ^. #timeoutSeconds)), ("exitCodes", obj [("accepted", json (codes ^. #accepted)), ("usage", json (codes ^. #usage)), ("sourceFailure", json (codes ^. #sourceFailure)), ("rejected", json (codes ^. #rejected))]), ("notes", json (r ^. #notes))]
  where
    RoleLabel label = r ^. #role
    codes = r ^. #exitCodes
    argumentJson (Argument [p]) = partJson p
    argumentJson (Argument ps) = obj [("parts", E.list partJson ps)]
    partJson = \case LiteralPart t -> obj [("literal", json t)]; FilePart n -> obj [("file", json (fileText n))]; DirectoryPart n -> obj [("directory", json (fileText n))]
    entryJson e =
      obj
        ( [("name", json (e ^. #name))] <> case e ^. #form of
            InlineValue t -> [("value", json t)]
            SecretFromEnvironment n -> [("secretFromEnvironment", json (envText n))]
            SecretFromFile p -> [("secretFromFile", json p)]
            SyntheticEntry sensitivity t reason -> [("synthetic", json t), ("reason", json reason), ("sensitivity", json (case sensitivity of Public -> "public" :: Text; Secret -> "secret"))]
            UnresolvedEntry t -> [("unresolved", json t)]
            UnavailableSecretEntry t -> [("unavailableSecret", json t)]
        )

-- | Injectable harness-side variable lookup and UTF-8 secret-file reads.
-- References do not imply inheritance into the checked child.
data RequestEnvironment = RequestEnvironment {lookupVariable :: !(EnvName -> IO (Maybe Text)), readSecretFile :: !(FilePath -> IO (Either Text Text))} deriving stock (Generic)

-- | Resolved proposal and run options. It may contain real secrets and has no Show
-- instance; pass it to the runner rather than logging it.
data ResolvedRequest = ResolvedRequest {role :: !RoleLabel, command :: !CheckCommand, inputs :: !ProposedInputs, options :: !PreflightOptions} deriving stock (Generic, Eq)

-- | Resolve against the harness process and working directory. Secret files use
-- strict UTF-8 without trimming; read failures become unavailable secret inputs.
processRequestEnvironment :: RequestEnvironment
processRequestEnvironment =
  RequestEnvironment
    (\(EnvName n) -> fmap T.pack <$> Env.lookupEnv (T.unpack n))
    (\p -> tryIO (BS.readFile p) >>= \case Left _ -> pure (Left "secret file could not be read"); Right bytes -> pure (case TE.decodeUtf8' bytes of Left _ -> Left "secret file is not UTF-8"; Right t -> Right t))

-- | Resolve references using the injected harness environment. Missing/unreadable
-- secrets remain unavailable inputs; public values and fake stand-ins retain their
-- classification. This neither runs the application nor proves complete coverage.
resolveRequest :: RequestEnvironment -> PreflightRequest -> IO ResolvedRequest
resolveRequest env r = do
  es <- traverse (\e -> (EnvName (e ^. #name),) <$> resolve (e ^. #form)) (r ^. #environment)
  fs <- traverse (\e -> ProposedFile (FileName (e ^. #name)) <$> resolve (e ^. #form)) (r ^. #files)
  pure (ResolvedRequest (r ^. #role) (r ^. #command) (ProposedInputs es fs (maybe defaultInherited id (r ^. #inherit)) (r ^. #notes)) (PreflightOptions (r ^. #timeoutSeconds) (r ^. #exitCodes)))
  where
    resolve = \case
      InlineValue t -> pure (SuppliedValue Public t)
      SyntheticEntry sensitivity t reason -> pure (SyntheticValue sensitivity t reason)
      UnresolvedEntry t -> pure (UnresolvedValue t)
      UnavailableSecretEntry t -> pure (UnavailableSecret t)
      SecretFromEnvironment n -> do
        found <- if validEnvironmentName n then tryIO ((env ^. #lookupVariable) n) else pure (Right Nothing)
        pure $ case found of Right (Just t) -> SuppliedValue Secret t; _ -> UnavailableSecret ("harness environment variable " <> envText n <> " is not set")
      SecretFromFile path -> do
        found <- tryIO ((env ^. #readSecretFile) path)
        pure $ case found of Right (Right t) -> SuppliedValue Secret t; _ -> UnavailableSecret ("secret file " <> T.pack path <> " could not be read")