packages feed

settei-yaml-0.2.0.0: src/Settei/Yaml.hs

{-# LANGUAGE ImportQualifiedPost #-}

-- |
-- Module: Settei.Yaml
-- Description: Strict, location-preserving YAML sources for Settei.
module Settei.Yaml
  ( YamlErrorCategory (..),
    YamlSourceError,
    YamlSourceOptions,
    annotateYamlSourceOptions,
    decodeYamlSource,
    fromKubernetesMountedFile,
    readYamlSource,
    renderYamlErrorText,
    renderYamlErrorsText,
    withYamlSourcePath,
    yamlErrorCategory,
    yamlErrorColumn,
    yamlErrorContext,
    yamlErrorLine,
    yamlErrorMessage,
    yamlErrorName,
    yamlErrorPath,
    yamlSourceAnnotations,
    yamlSourceName,
    yamlSourceOptions,
    yamlSourcePath,
  )
where

import Control.Applicative ((<|>))
import Control.Exception
  ( AsyncException,
    IOException,
    SomeAsyncException,
    SomeException,
    displayException,
    fromException,
    throwIO,
    try,
  )
import Control.Monad (when)
import Data.Attoparsec.Text qualified as Attoparsec
import Data.Bifunctor (first)
import Data.ByteString (ByteString)
import Data.ByteString qualified as ByteString
import Data.Char (ord)
import Data.Conduit (runConduitRes, (.|))
import Data.Conduit.List qualified as ConduitList
import Data.Generics.Labels ()
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict qualified as Map
import Data.Ratio qualified as Ratio
import Data.Scientific (Scientific, base10Exponent)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TextEncoding
import Settei
import Settei.Prelude
import System.IO.Unsafe (unsafePerformIO)
import Text.Libyaml qualified as Libyaml

-- | Stable classes of adapter failure. No constructor retains a raw YAML value.
data YamlErrorCategory
  = YamlSyntaxError
  | YamlIoError
  | YamlMultipleDocuments
  | YamlDuplicateKey
  | YamlNonStringKey
  | YamlDottedKey
  | YamlUnsupportedFeature
  | YamlInvalidScalar
  | YamlTopLevelType
  deriving stock (Generic, Eq, Ord, Show)

-- | A secret-safe YAML input failure.
data YamlSourceError = YamlSourceError
  { category :: !YamlErrorCategory,
    name :: !Text,
    path :: !(Maybe FilePath),
    line :: !(Maybe Int),
    column :: !(Maybe Int),
    context :: !Text,
    message :: !Text
  }
  deriving stock (Generic, Eq, Show)

-- | How one YAML document should be named and annotated in provenance.
data YamlSourceOptions = YamlSourceOptions
  { name :: !Text,
    path :: !(Maybe FilePath),
    annotations :: !(Map Text Text)
  }
  deriving stock (Generic, Eq)

data PathPiece = MappingKey !Text | SequenceIndex !Int
  deriving stock (Generic, Eq, Ord)

type LocationMap = Map [Text] SourceLocation

-- | Start options for an in-memory YAML document.
yamlSourceOptions :: Text -> YamlSourceOptions
yamlSourceOptions name = YamlSourceOptions {name, path = Nothing, annotations = Map.empty}

-- | Attach a real or logical file path to an in-memory document.
withYamlSourcePath :: FilePath -> YamlSourceOptions -> YamlSourceOptions
withYamlSourcePath path options = options & #path .~ Just path

-- | Add trusted, secret-safe origin metadata.
annotateYamlSourceOptions :: Map Text Text -> YamlSourceOptions -> YamlSourceOptions
annotateYamlSourceOptions annotations options = options & #annotations %~ (annotations <>)

-- | Attach a trusted Kubernetes reference for a mounted YAML file.
--
-- This performs no cluster lookup. Setting sensitivity remains the sole redaction policy.
fromKubernetesMountedFile :: KubernetesRef -> YamlSourceOptions -> YamlSourceOptions
fromKubernetesMountedFile reference =
  annotateYamlSourceOptions (kubernetesAnnotations reference)

-- | Return the stable source name.
yamlSourceName :: YamlSourceOptions -> Text
yamlSourceName options = options ^. #name

-- | Return the optional file path.
yamlSourcePath :: YamlSourceOptions -> Maybe FilePath
yamlSourcePath options = options ^. #path

-- | Return caller-supplied, secret-safe annotations.
yamlSourceAnnotations :: YamlSourceOptions -> Map Text Text
yamlSourceAnnotations options = options ^. #annotations

-- | Return an error's stable category.
yamlErrorCategory :: YamlSourceError -> YamlErrorCategory
yamlErrorCategory problem = problem ^. #category

-- | Return the source name associated with an error.
yamlErrorName :: YamlSourceError -> Text
yamlErrorName problem = problem ^. #name

-- | Return the file path associated with an error, when known.
yamlErrorPath :: YamlSourceError -> Maybe FilePath
yamlErrorPath problem = problem ^. #path

-- | Return the one-based line associated with an error, when known.
yamlErrorLine :: YamlSourceError -> Maybe Int
yamlErrorLine problem = problem ^. #line

-- | Return the one-based column associated with an error, when known.
yamlErrorColumn :: YamlSourceError -> Maybe Int
yamlErrorColumn problem = problem ^. #column

-- | Return a structural path such as @$.service.ports[0]@.
yamlErrorContext :: YamlSourceError -> Text
yamlErrorContext problem = problem ^. #context

-- | Return a concise message that never includes a raw scalar or source excerpt.
yamlErrorMessage :: YamlSourceError -> Text
yamlErrorMessage problem = problem ^. #message

-- | Render one YAML input failure as a single operator-readable line.
--
-- The line leads with the stable source name, then a parenthetical built from the
-- colon-joined available location pieces (path, one-based line, one-based column),
-- then the structural context, then the fixed secret-safe message. Absent pieces
-- are omitted; when no piece is known the parenthetical disappears. No raw
-- configuration value can appear because 'YamlSourceError' retains none.
renderYamlErrorText :: YamlSourceError -> Text
renderYamlErrorText problem =
  problem ^. #name
    <> locationText
    <> " at "
    <> problem ^. #context
    <> ": "
    <> problem ^. #message
  where
    locationText = case locationPieces of
      [] -> ""
      pieces -> " (" <> Text.intercalate ":" pieces <> ")"
    locationPieces =
      maybe [] (pure . Text.pack) (problem ^. #path)
        <> maybe [] (pure . Text.pack . show) (problem ^. #line)
        <> maybe [] (pure . Text.pack . show) (problem ^. #column)

-- | Render every YAML input failure, one line per problem, matching
-- 'Settei.Render.renderErrorsText' in shape and trailing newline.
renderYamlErrorsText :: NonEmpty YamlSourceError -> Text
renderYamlErrorsText = Text.unlines . fmap renderYamlErrorText . NonEmpty.toList

-- | Decode exactly one YAML mapping into a Settei file source.
--
-- Duplicate keys, non-string keys, dotted keys, aliases, anchors, merge keys, custom
-- tags, special floating values, and multiple documents fail explicitly.
decodeYamlSource :: YamlSourceOptions -> ByteString -> Either (NonEmpty YamlSourceError) Source
decodeYamlSource options bytes =
  first NonEmpty.singleton $ do
    events <- decodeMarkedEvents options bytes
    (root, locations) <- parseDocument options events
    pure
      ( locateSource
          (locationFor locations)
          ( annotateSource
              (options ^. #annotations)
              (source (options ^. #name) (FileSource "YAML") root)
          )
      )

-- | Read one file and pass it through the same strict pure translator.
--
-- The supplied file path replaces any path already present in the options.
readYamlSource :: YamlSourceOptions -> FilePath -> IO (Either (NonEmpty YamlSourceError) Source)
readYamlSource options filePath = do
  let fileOptions = withYamlSourcePath filePath options
  contents <- try @IOException (ByteString.readFile filePath)
  pure $ case contents of
    Left exception ->
      Left
        ( NonEmpty.singleton
            ( yamlError
                fileOptions
                YamlIoError
                Nothing
                []
                (Text.pack (displayException exception))
            )
        )
    Right bytes -> decodeYamlSource fileOptions bytes

decodeMarkedEvents :: YamlSourceOptions -> ByteString -> Either YamlSourceError [Libyaml.MarkedEvent]
decodeMarkedEvents options bytes = unsafePerformIO $ do
  decoded <-
    try @SomeException
      (runConduitRes (Libyaml.decodeMarked bytes .| ConduitList.consume))
  case decoded of
    Right events -> pure (Right events)
    Left exception
      | Just (_ :: SomeAsyncException) <- fromException exception -> throwIO exception
      | Just asynchronous <- fromException @AsyncException exception -> throwIO asynchronous
      | Just yamlException <- fromException @Libyaml.YamlException exception ->
          pure (Left (syntaxError options yamlException))
      | otherwise ->
          pure
            ( Left
                ( yamlError
                    options
                    YamlSyntaxError
                    Nothing
                    []
                    "YAML decoding failed with an unexpected error"
                )
            )
{-# NOINLINE decodeMarkedEvents #-}

syntaxError :: YamlSourceOptions -> Libyaml.YamlException -> YamlSourceError
syntaxError options = \case
  Libyaml.YamlParseException problem parserContext mark ->
    yamlError
      options
      YamlSyntaxError
      (Just mark)
      []
      ( Text.intercalate
          ": "
          (filter (not . Text.null) (fmap Text.pack [parserContext, problem]))
      )
  Libyaml.YamlException message ->
    yamlError options YamlSyntaxError Nothing [] (Text.pack message)

parseDocument :: YamlSourceOptions -> [Libyaml.MarkedEvent] -> Either YamlSourceError (RawValue, LocationMap)
parseDocument options events = case events of
  firstEvent : rest
    | eventOf firstEvent == Libyaml.EventStreamStart -> parseStream rest
  _ -> Left (unexpectedStructure options Nothing [])
  where
    parseStream = \case
      finalEvent : []
        | eventOf finalEvent == Libyaml.EventStreamEnd ->
            Right (RawObject Map.empty, Map.empty)
      documentStart : rest
        | eventOf documentStart == Libyaml.EventDocumentStart -> do
            (root, locations, remaining) <- case rest of
              documentEnd : trailing
                | eventOf documentEnd == Libyaml.EventDocumentEnd ->
                    Right (RawObject Map.empty, Map.empty, trailing)
              _ -> do
                (value, foundLocations, afterValue) <- parseNode options [] (Just []) rest
                afterEnd <- requireEvent options Libyaml.EventDocumentEnd afterValue
                Right (value, foundLocations, afterEnd)
            case remaining of
              next : _
                | eventOf next == Libyaml.EventDocumentStart ->
                    Left (yamlError options YamlMultipleDocuments (Just (startMark next)) [] "multiple YAML documents are not supported")
              streamEnd : []
                | eventOf streamEnd == Libyaml.EventStreamEnd ->
                    case root of
                      RawObject _ -> Right (root, locations)
                      RawNull -> Right (RawObject Map.empty, Map.empty)
                      _ -> Left (yamlError options YamlTopLevelType Nothing [] "the top-level YAML value must be a mapping")
              _ -> Left (unexpectedStructure options Nothing [])
      _ -> Left (unexpectedStructure options Nothing [])

parseNode ::
  YamlSourceOptions ->
  [PathPiece] ->
  Maybe [Text] ->
  [Libyaml.MarkedEvent] ->
  Either YamlSourceError (RawValue, LocationMap, [Libyaml.MarkedEvent])
parseNode options context keyPath = \case
  [] -> Left (unexpectedStructure options Nothing context)
  marked : rest -> case eventOf marked of
    Libyaml.EventScalar bytes tag style anchor -> do
      rejectAnchor options context marked anchor
      value <- scalarValue options context marked bytes tag style
      Right (value, locationMap options keyPath marked, rest)
    Libyaml.EventSequenceStart tag _ anchor -> do
      rejectAnchor options context marked anchor
      rejectCollectionTag options context marked Libyaml.SeqTag tag
      (values, remaining) <- parseSequence options context 0 rest
      Right (RawArray values, locationMap options keyPath marked, remaining)
    Libyaml.EventMappingStart tag _ anchor -> do
      rejectAnchor options context marked anchor
      rejectCollectionTag options context marked Libyaml.MapTag tag
      (object, childLocations, remaining) <- parseMapping options context keyPath Map.empty Map.empty rest
      Right
        ( RawObject object,
          locationMap options keyPath marked <> childLocations,
          remaining
        )
    Libyaml.EventAlias _ ->
      Left (yamlError options YamlUnsupportedFeature (Just (startMark marked)) context "YAML aliases are not supported")
    _ -> Left (unexpectedStructure options (Just (startMark marked)) context)

parseSequence ::
  YamlSourceOptions ->
  [PathPiece] ->
  Int ->
  [Libyaml.MarkedEvent] ->
  Either YamlSourceError ([RawValue], [Libyaml.MarkedEvent])
parseSequence options context position = \case
  [] -> Left (unexpectedStructure options Nothing context)
  marked : rest
    | eventOf marked == Libyaml.EventSequenceEnd -> Right ([], rest)
    | otherwise -> do
        (value, _, remaining) <- parseNode options (context <> [SequenceIndex position]) Nothing (marked : rest)
        (values, finalEvents) <- parseSequence options context (position + 1) remaining
        Right (value : values, finalEvents)

parseMapping ::
  YamlSourceOptions ->
  [PathPiece] ->
  Maybe [Text] ->
  Map Text RawValue ->
  LocationMap ->
  [Libyaml.MarkedEvent] ->
  Either YamlSourceError (Map Text RawValue, LocationMap, [Libyaml.MarkedEvent])
parseMapping options context keyPath object locations = \case
  [] -> Left (unexpectedStructure options Nothing context)
  marked : rest
    | eventOf marked == Libyaml.EventMappingEnd -> Right (object, locations, rest)
    | otherwise -> do
        key <- mappingKey options context marked
        let keyContext = context <> [MappingKey key]
        when (key == "<<") $
          Left (yamlError options YamlUnsupportedFeature (Just (startMark marked)) keyContext "YAML merge keys are not supported")
        when (Text.any (== '.') key) $
          Left (yamlError options YamlDottedKey (Just (startMark marked)) keyContext "mapping keys containing dots are not supported; use nested mappings")
        when (Map.member key object) $
          Left (yamlError options YamlDuplicateKey (Just (startMark marked)) keyContext "duplicate mapping key")
        let childKeyPath = fmap (<> [key]) keyPath
        (value, childLocations, remaining) <- parseNode options keyContext childKeyPath rest
        parseMapping
          options
          context
          keyPath
          (Map.insert key value object)
          (locations <> childLocations)
          remaining

mappingKey :: YamlSourceOptions -> [PathPiece] -> Libyaml.MarkedEvent -> Either YamlSourceError Text
mappingKey options context marked = case eventOf marked of
  Libyaml.EventScalar bytes tag style anchor -> do
    rejectAnchor options context marked anchor
    value <- scalarValue options context marked bytes tag style
    case value of
      RawText key -> Right key
      _ -> Left (yamlError options YamlNonStringKey (Just (startMark marked)) context "mapping keys must be strings")
  _ -> Left (yamlError options YamlNonStringKey (Just (startMark marked)) context "mapping keys must be scalar strings")

scalarValue ::
  YamlSourceOptions ->
  [PathPiece] ->
  Libyaml.MarkedEvent ->
  ByteString ->
  Libyaml.Tag ->
  Libyaml.Style ->
  Either YamlSourceError RawValue
scalarValue options context marked bytes tag style = do
  value <-
    first
      (const (yamlError options YamlInvalidScalar (Just (startMark marked)) context "scalar is not valid UTF-8"))
      (TextEncoding.decodeUtf8' bytes)
  case tag of
    Libyaml.StrTag -> Right (RawText value)
    Libyaml.NullTag -> Right RawNull
    Libyaml.BoolTag -> parseBoolean options context marked value
    Libyaml.IntTag -> parseTaggedInteger options context marked value
    Libyaml.FloatTag ->
      RawNumber <$> (parseNumber options context marked value >>= scalarRational options context marked)
    Libyaml.NoTag
      | style `elem` [Libyaml.SingleQuoted, Libyaml.DoubleQuoted, Libyaml.Literal, Libyaml.Folded] ->
          Right (RawText value)
      | isNull value -> Right RawNull
      | Just boolean <- yamlBoolean value -> Right (RawBool boolean)
      | Right number <- parseYamlNumber value -> RawNumber <$> scalarRational options context marked number
      | otherwise -> Right (RawText value)
    _ -> Left (yamlError options YamlUnsupportedFeature (Just (startMark marked)) context "this YAML scalar tag is not supported")

parseBoolean :: YamlSourceOptions -> [PathPiece] -> Libyaml.MarkedEvent -> Text -> Either YamlSourceError RawValue
parseBoolean options context marked value =
  maybe
    (Left (yamlError options YamlInvalidScalar (Just (startMark marked)) context "invalid boolean scalar"))
    (Right . RawBool)
    (yamlBoolean value)

parseTaggedInteger :: YamlSourceOptions -> [PathPiece] -> Libyaml.MarkedEvent -> Text -> Either YamlSourceError RawValue
parseTaggedInteger options context marked value = do
  number <- parseNumber options context marked value
  rational <- scalarRational options context marked number
  if Ratio.denominator rational == 1
    then Right (RawNumber rational)
    else Left (yamlError options YamlInvalidScalar (Just (startMark marked)) context "integer tag requires a whole number")

parseNumber :: YamlSourceOptions -> [PathPiece] -> Libyaml.MarkedEvent -> Text -> Either YamlSourceError Scientific
parseNumber options context marked value =
  first
    (const (yamlError options YamlInvalidScalar (Just (startMark marked)) context "invalid or non-finite numeric scalar"))
    (parseYamlNumber value)

-- | Largest absolute base-10 exponent accepted for a numeric scalar.
--
-- 'toRational' materializes @10 ^ exponent@ as an exact 'Integer', so cost
-- grows with the exponent's magnitude rather than the input's length. This
-- adapter-local bound leaves core 'RawNumber' values unbounded.
maximumScalarExponent :: Int
maximumScalarExponent = 4096

scalarRational ::
  YamlSourceOptions ->
  [PathPiece] ->
  Libyaml.MarkedEvent ->
  Scientific ->
  Either YamlSourceError Rational
scalarRational options context marked number
  | scalarExponent < negate maximumScalarExponent || scalarExponent > maximumScalarExponent =
      Left
        ( yamlError
            options
            YamlInvalidScalar
            (Just (startMark marked))
            context
            "numeric scalar exponent is out of the supported range"
        )
  | otherwise = Right (toRational number)
  where
    scalarExponent = base10Exponent number

parseYamlNumber :: Text -> Either String Scientific
parseYamlNumber = Attoparsec.parseOnly (yamlNumberParser <* Attoparsec.endOfInput)

yamlNumberParser :: Attoparsec.Parser Scientific
yamlNumberParser =
  (fromInteger <$> ("0x" *> Attoparsec.hexadecimal))
    <|> (fromInteger <$> ("0o" *> octal))
    <|> Attoparsec.scientific
  where
    octal = Text.foldl' step 0 <$> Attoparsec.takeWhile1 isOctalDigit
    isOctalDigit character = character >= '0' && character <= '7'
    step accumulator character = accumulator * 8 + fromIntegral (ord character - ord '0')

yamlBoolean :: Text -> Maybe Bool
yamlBoolean value = case Text.toCaseFold value of
  "true" -> Just True
  "false" -> Just False
  _ -> Nothing

isNull :: Text -> Bool
isNull value = Text.toCaseFold value == "null" || value == "~" || Text.null value

rejectAnchor :: YamlSourceOptions -> [PathPiece] -> Libyaml.MarkedEvent -> Libyaml.Anchor -> Either YamlSourceError ()
rejectAnchor options context marked = \case
  Nothing -> Right ()
  Just _ -> Left (yamlError options YamlUnsupportedFeature (Just (startMark marked)) context "YAML anchors are not supported")

rejectCollectionTag ::
  YamlSourceOptions ->
  [PathPiece] ->
  Libyaml.MarkedEvent ->
  Libyaml.Tag ->
  Libyaml.Tag ->
  Either YamlSourceError ()
rejectCollectionTag options context marked expected actual
  | actual == Libyaml.NoTag || actual == expected = Right ()
  | otherwise = Left (yamlError options YamlUnsupportedFeature (Just (startMark marked)) context "this YAML collection tag is not supported")

requireEvent ::
  YamlSourceOptions ->
  Libyaml.Event ->
  [Libyaml.MarkedEvent] ->
  Either YamlSourceError [Libyaml.MarkedEvent]
requireEvent options expected = \case
  marked : rest
    | eventOf marked == expected -> Right rest
    | otherwise -> Left (unexpectedStructure options (Just (startMark marked)) [])
  [] -> Left (unexpectedStructure options Nothing [])

locationMap :: YamlSourceOptions -> Maybe [Text] -> Libyaml.MarkedEvent -> LocationMap
locationMap options keyPath marked = case keyPath of
  Just segments@(_ : _) -> Map.singleton segments (sourceLocation options (startMark marked))
  _ -> Map.empty

locationFor :: LocationMap -> Key -> Maybe SourceLocation
locationFor locations key = Map.lookup (NonEmpty.toList (keySegments key)) locations

sourceLocation :: YamlSourceOptions -> Libyaml.YamlMark -> SourceLocation
sourceLocation options (Libyaml.YamlMark _ zeroBasedLine zeroBasedColumn) =
  SourceLocation
    { path = maybe (options ^. #name) Text.pack (options ^. #path),
      line = Just (zeroBasedLine + 1),
      column = Just (zeroBasedColumn + 1)
    }

yamlError ::
  YamlSourceOptions ->
  YamlErrorCategory ->
  Maybe Libyaml.YamlMark ->
  [PathPiece] ->
  Text ->
  YamlSourceError
yamlError options category mark context message =
  YamlSourceError
    { category,
      name = options ^. #name,
      path = options ^. #path,
      line = oneBasedLine <$> mark,
      column = oneBasedColumn <$> mark,
      context = renderContext context,
      message
    }

unexpectedStructure :: YamlSourceOptions -> Maybe Libyaml.YamlMark -> [PathPiece] -> YamlSourceError
unexpectedStructure options mark context =
  yamlError options YamlSyntaxError mark context "unexpected YAML event sequence"

renderContext :: [PathPiece] -> Text
renderContext = foldl renderPiece "$"
  where
    renderPiece rendered (MappingKey key) = rendered <> "." <> key
    renderPiece rendered (SequenceIndex position) = rendered <> "[" <> Text.pack (show position) <> "]"

eventOf :: Libyaml.MarkedEvent -> Libyaml.Event
eventOf (Libyaml.MarkedEvent event _ _) = event

startMark :: Libyaml.MarkedEvent -> Libyaml.YamlMark
startMark (Libyaml.MarkedEvent _ mark _) = mark

oneBasedLine :: Libyaml.YamlMark -> Int
oneBasedLine (Libyaml.YamlMark _ line _) = line + 1

oneBasedColumn :: Libyaml.YamlMark -> Int
oneBasedColumn (Libyaml.YamlMark _ _ column) = column + 1