packages feed

hurl-workbench-core-0.1.0.0: src/HurlWorkbench/Suite/Scenario.hs

-- | Private caller-owned fixture assignments. Only full verification produces
-- executable per-scenario inputs; raw provider values never appear in errors.
module HurlWorkbench.Suite.Scenario
  ( ScenarioInputs (..),
    ScenarioAssignment (..),
    ScenarioBinding (..),
    ScenarioSecret (..),
    VerifiedScenarioInputs,
    ScenarioIssue (..),
    decodeScenarioInputs,
    loadScenarioInputs,
    verifyScenarioInputs,
    scenarioBindingInput,
    renderScenarioIssue,
  )
where

import Control.Exception (IOException, try)
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (Parser)
import Data.Bifunctor (first)
import Data.Bits ((.&.))
import Data.ByteString qualified as ByteString
import Data.ByteString.Char8 qualified as Char8
import Data.Char (isControl, isSpace)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import HurlWorkbench.Parameter.Properties
import HurlWorkbench.Parameter.Resolve
import HurlWorkbench.Prelude
import HurlWorkbench.Run.Selection
import HurlWorkbench.Workspace.Context
import HurlWorkbench.Workspace.Types
import HurlWorkbench.Workspace.Validate (isValidEntityName, isValidEnvironmentName)
import Network.HTTP.Client qualified as HTTP
import System.FilePath (splitDirectories)
import System.Posix.Files
  ( fileMode,
    getSymbolicLinkStatus,
    groupModes,
    isRegularFile,
    otherModes,
    unionFileModes,
  )

newtype ScenarioInputs = ScenarioInputs [ScenarioAssignment]
  deriving stock (Generic, Eq)

data ScenarioAssignment = ScenarioAssignment
  { scenario :: !FilePath,
    fixtureId :: !Text,
    endpoint :: !Text,
    endpointParameter :: !ParameterName,
    bindings :: ![ScenarioBinding],
    secretEnvironment :: ![ScenarioSecret]
  }
  deriving stock (Generic, Eq)

data ScenarioBinding = ScenarioBinding
  { parameter :: !ParameterName,
    value :: !Text
  }
  deriving stock (Generic, Eq)

data ScenarioSecret = ScenarioSecret
  { parameter :: !ParameterName,
    variable :: !Text
  }
  deriving stock (Generic, Eq)

newtype VerifiedScenarioInputs = VerifiedScenarioInputs (Map FilePath BindingInput)

instance Show ScenarioInputs where show _ = "ScenarioInputs <redacted>"

instance Show ScenarioAssignment where show _ = "ScenarioAssignment <redacted>"

instance Show ScenarioBinding where show _ = "ScenarioBinding <redacted>"

instance Show ScenarioSecret where show _ = "ScenarioSecret <redacted>"

instance Show VerifiedScenarioInputs where show _ = "VerifiedScenarioInputs <redacted>"

data ScenarioIssue
  = ScenarioFileUnreadable
  | ScenarioFileNotPrivateRegular
  | ScenarioMalformedInput
  | ScenarioUnsupportedVersion
  | ScenarioInvalidLabel
  | ScenarioAssignmentMismatch
  | ScenarioDuplicateFixture
  | ScenarioInvalidFixture
  | ScenarioDuplicateEndpoint
  | ScenarioInvalidEndpoint
  | ScenarioInvalidParameter
  | ScenarioDuplicateParameter
  | ScenarioConflictingBinding
  | ScenarioRuntimeInputFailed
  deriving stock (Generic, Eq, Show)

instance Aeson.FromJSON ScenarioInputs where
  parseJSON = Aeson.withObject "scenario-inputs" $ \object -> do
    exactFields ["version", "scenarios"] object
    version <- object Aeson..: "version" :: Parser Int
    unless (version == 1) (fail "unsupported-version")
    ScenarioInputs <$> object Aeson..: "scenarios"

instance Aeson.FromJSON ScenarioAssignment where
  parseJSON = Aeson.withObject "assignment" $ \object -> do
    exactFields ["scenario", "fixtureId", "endpoint", "endpointParameter", "bindings", "secretEnvironment"] object
    ScenarioAssignment <$> object Aeson..: "scenario" <*> object Aeson..: "fixtureId" <*> object Aeson..: "endpoint" <*> (ParameterName <$> object Aeson..: "endpointParameter") <*> object Aeson..: "bindings" <*> object Aeson..: "secretEnvironment"

instance Aeson.FromJSON ScenarioBinding where
  parseJSON = Aeson.withObject "binding" $ \object -> do
    exactFields ["parameter", "value"] object
    ScenarioBinding <$> (ParameterName <$> object Aeson..: "parameter") <*> object Aeson..: "value"

instance Aeson.FromJSON ScenarioSecret where
  parseJSON = Aeson.withObject "secret" $ \object -> do
    exactFields ["parameter", "variable"] object
    ScenarioSecret <$> (ParameterName <$> object Aeson..: "parameter") <*> object Aeson..: "variable"

exactFields :: [Aeson.Key] -> Aeson.Object -> Parser ()
exactFields expected object = unless (Set.fromList expected == Set.fromList (KeyMap.keys object)) (fail "invalid-fields")

decodeScenarioInputs :: ByteString.ByteString -> Either ScenarioIssue ScenarioInputs
decodeScenarioInputs bytes = case Aeson.eitherDecodeStrict @Aeson.Value bytes of
  Left _ -> Left ScenarioMalformedInput
  Right (Aeson.Object object)
    | Just (Aeson.Number version) <- KeyMap.lookup "version" object, version /= 1 -> Left ScenarioUnsupportedVersion
  Right _ -> first (const ScenarioMalformedInput) (Aeson.eitherDecodeStrict bytes)

loadScenarioInputs :: FilePath -> IO (Either ScenarioIssue ScenarioInputs)
loadScenarioInputs path = do
  status <- try @IOException (getSymbolicLinkStatus path)
  case status of
    Left _ -> pure (Left ScenarioFileUnreadable)
    Right file
      | not (isRegularFile file) || fileMode file .&. (groupModes `unionFileModes` otherModes) /= 0 -> pure (Left ScenarioFileNotPrivateRegular)
      | otherwise -> fmap (either (const (Left ScenarioFileUnreadable)) decodeScenarioInputs) (try @IOException (ByteString.readFile path))

-- | Validate the entire assignment and common input before any run preparation.
-- File channels are inspected for conflicting names; the existing binding
-- resolver retains ordinary suite input precedence.
verifyScenarioInputs :: ValidatedWorkspace -> [ExpandedRun] -> BindingInput -> ScenarioInputs -> IO (Either ScenarioIssue VerifiedScenarioInputs)
verifyScenarioInputs workspace runs common (ScenarioInputs assignments) = do
  loaded <- snapshotInputs common
  origins <- traverse (endpointOrigin . view #endpoint) assignments
  pure $ do
    (input, fileNames) <- loaded
    endpointKeys <- sequence origins
    let expected = map (view #artifactStem) runs
        labels = map (view #scenario) assignments
    ensure (all validLabel labels) ScenarioInvalidLabel
    ensure (unique expected && unique labels && Set.fromList expected == Set.fromList labels) ScenarioAssignmentMismatch
    ensure (all (validFixture . view #fixtureId) assignments) ScenarioInvalidFixture
    ensure (unique (map (view #fixtureId) assignments)) ScenarioDuplicateFixture
    ensure (unique endpointKeys) ScenarioDuplicateEndpoint
    let assignmentsByStem = Map.fromList [(assignment ^. #scenario, assignment) | assignment <- assignments]
    verified <- traverse (verifyRun input fileNames assignmentsByStem) runs
    pure (VerifiedScenarioInputs (Map.fromList verified))
  where
    verifyRun input fileNames assignmentsByStem run = do
      assignment <- maybe (Left ScenarioAssignmentMismatch) Right (Map.lookup (run ^. #artifactStem) assignmentsByStem)
      let endpointName = assignment ^. #endpointParameter
          plain = (endpointName, assignment ^. #endpoint) : [(binding ^. #parameter, binding ^. #value) | binding <- assignment ^. #bindings]
          secret = [(binding ^. #parameter, binding ^. #variable) | binding <- assignment ^. #secretEnvironment]
          names = map fst plain <> map fst secret
          supplied = Set.fromList names
          committed = Set.unions (map (Map.keysSet . view #values) (run ^. #bindingLayers))
          runtime = Map.keysSet (input ^. #plainOverrides) <> Map.keysSet (input ^. #secretEnvironmentOverrides)
          declared = Set.fromList (run ^. #workflow . #workflow . #parameters)
          checkKind kind name = name `Set.member` declared && maybe False ((== kind) . view #kind) (lookupParameter name workspace)
      ensure (unique names) ScenarioDuplicateParameter
      ensure (all (checkKind Plain . fst) plain && all (checkKind Secret . fst) secret && all (isValidEnvironmentName . snd) secret) ScenarioInvalidParameter
      ensure (Set.null (supplied `Set.intersection` (committed <> runtime <> fileNames))) ScenarioConflictingBinding
      values <- traverse (\(name, value) -> (name,) <$> first (const ScenarioRuntimeInputFailed) (mkHurlValueLiteral value)) plain
      pure (run ^. #artifactStem, input {plainOverrides = Map.fromList values <> input ^. #plainOverrides, secretEnvironmentOverrides = Map.fromList secret <> input ^. #secretEnvironmentOverrides})

-- | Verified assignments are immutable; callers cannot manufacture a verified
-- value or change its mapping. Only expanded logical scenarios can select input.
scenarioBindingInput :: VerifiedScenarioInputs -> ExpandedRun -> Maybe BindingInput
scenarioBindingInput (VerifiedScenarioInputs inputs) run = Map.lookup (run ^. #artifactStem) inputs

snapshotInputs :: BindingInput -> IO (Either ScenarioIssue (BindingInput, Set ParameterName))
snapshotInputs input = do
  variables <- traverse (load parseVariableProperties) (input ^. #variableFiles)
  secrets <- traverse (load parseSecretProperties) (input ^. #secretFiles)
  pure $ do
    plainFiles <- sequence variables
    secretMaps <- sequence secrets
    pure (input, Set.unions (map Map.keysSet plainFiles <> map Map.keysSet secretMaps))
  where
    load parser path = do
      bytes <- try @IOException (ByteString.readFile path)
      pure $ first (const ScenarioRuntimeInputFailed) (bytes >>= first (const (userError "invalid runtime input")) . parser path)

endpointOrigin :: Text -> IO (Either ScenarioIssue (Bool, Text, Int))
endpointOrigin endpoint
  | Text.any (\c -> isControl c || isSpace c || c `elem` ("@?#" :: String)) endpoint = pure (Left ScenarioInvalidEndpoint)
  | not (any (`Text.isPrefixOf` lowered) ["http://", "https://"]) = pure (Left ScenarioInvalidEndpoint)
  | otherwise = do
      parsed <- try @HTTP.HttpException (HTTP.parseRequest (Text.unpack lowered))
      pure $ case parsed of
        Left _ -> Left ScenarioInvalidEndpoint
        Right request
          | HTTP.path request /= "/" || ByteString.null (HTTP.host request) || HTTP.port request < 1 || HTTP.port request > 65535 -> Left ScenarioInvalidEndpoint
          | otherwise -> Right (HTTP.secure request, Text.toLower (Text.pack (Char8.unpack (HTTP.host request))), HTTP.port request)
  where
    lowered = Text.toLower endpoint

validLabel :: FilePath -> Bool
validLabel path = not (null path) && all (isValidEntityName . Text.pack) (splitDirectories path)

validFixture :: Text -> Bool
validFixture value = not (Text.null value) && not (Text.any isControl value)

unique :: (Ord a) => [a] -> Bool
unique values = Set.size (Set.fromList values) == length values

ensure :: Bool -> issue -> Either issue ()
ensure condition issue = if condition then Right () else Left issue

renderScenarioIssue :: ScenarioIssue -> Text
renderScenarioIssue = \case
  ScenarioFileUnreadable -> "scenario-file-unreadable"
  ScenarioFileNotPrivateRegular -> "scenario-file-not-private-regular"
  ScenarioMalformedInput -> "scenario-malformed-input"
  ScenarioUnsupportedVersion -> "scenario-unsupported-version"
  ScenarioInvalidLabel -> "scenario-invalid-label"
  ScenarioAssignmentMismatch -> "scenario-assignment-mismatch"
  ScenarioDuplicateFixture -> "scenario-duplicate-fixture"
  ScenarioInvalidFixture -> "scenario-invalid-fixture"
  ScenarioDuplicateEndpoint -> "scenario-duplicate-endpoint"
  ScenarioInvalidEndpoint -> "scenario-invalid-endpoint"
  ScenarioInvalidParameter -> "scenario-invalid-parameter"
  ScenarioDuplicateParameter -> "scenario-duplicate-parameter"
  ScenarioConflictingBinding -> "scenario-conflicting-binding"
  ScenarioRuntimeInputFailed -> "scenario-runtime-input-failed"