packages feed

plexus-synapse-3.15.0: src/Synapse/Self/Resolve.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- | URI parsing and resolver registry for credential references.
--
-- A 'CredentialRef' on disk is one of:
--
-- * Opaque: @literal:\<rawValue\>@ — value taken verbatim after the first
--   colon. No URL-encoding so JWTs / base64 fit cleanly.
-- * Hierarchical: @scheme:\/\/authority\/path[?query]@ — interpretation
--   is scheme-specific.
--
-- Parsing is shape-only: 'parseUri' accepts any well-formed URI. Scheme
-- validity is enforced at *resolve* time via the registry, so the file
-- parses even when a scheme\'s backend (e.g. @vault@) is not wired up.
--
-- The registry is an open map; concrete resolvers (e.g. @keychain@,
-- @env@, @file@) are registered by their owning packages via simple
-- 'Map' insertion. An empty registry (@mempty@) returns
-- 'ResolveUnknownScheme' for every lookup.
module Synapse.Self.Resolve
  ( -- * Parsed URI shape
    ParsedUri(..)
  , ParsedUriBody(..)
  , parseUri

    -- * Resolver interface
  , Resolver(..)
  , ResolveFn
  , NamedResolver(..)

    -- * Registry
  , ResolverRegistry(..)
  , emptyRegistry
  , registerResolver
  , registerNamedResolver
  , lookupResolver
  , resolveRef

    -- * Errors
  , ResolveError(..)
  ) where

import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Proxy (Proxy)
import Data.Text (Text)
import qualified Data.Text as T

import Synapse.Self.Types (CredentialRef(..))

-- ============================================================================
-- Parsed URI
-- ============================================================================

-- | Body of a parsed URI.
--
-- We keep this deliberately simple — this is not RFC 3986. We only need
-- to separate the opaque escape hatch from the @scheme://authority/path@
-- shape, plus a light query split so @env:\/\/VAR?fallback=x@ style
-- extensions are possible later without another parser pass.
data ParsedUriBody
  = OpaqueBody Text
    -- ^ Everything after the first @:@ for non-hierarchical URIs.
  | HierarchicalBody
      { puAuthority :: Text
      , puPath      :: Text
      , puQuery     :: [(Text, Text)]
      }
  deriving stock (Show, Eq)

-- | A parsed credential URI.
data ParsedUri = ParsedUri
  { puScheme :: Text
  , puBody   :: ParsedUriBody
  }
  deriving stock (Show, Eq)

-- | Parse a 'CredentialRef' URI.
--
-- Splits on the first @:@ to extract the scheme. If the remainder starts
-- with @\/\/@ we treat it as hierarchical (authority + path + optional
-- query); otherwise the remainder is taken verbatim as the opaque body.
--
-- Rejected shapes:
--
-- * bare strings with no scheme (e.g. @"abc"@)
-- * empty scheme (e.g. @":foo"@)
parseUri :: CredentialRef -> Either Text ParsedUri
parseUri (CredentialRef raw) =
  case T.breakOn ":" raw of
    (_, "") -> Left ("missing scheme in credential reference: " <> raw)
    (scheme, colonRest)
      | T.null scheme -> Left ("empty scheme in credential reference: " <> raw)
      | otherwise ->
          let rest = T.drop 1 colonRest
          in case T.stripPrefix "//" rest of
               Just hier -> Right (ParsedUri scheme (parseHierarchical hier))
               Nothing   -> Right (ParsedUri scheme (OpaqueBody rest))

-- | Split @authority/path?query@. Path and authority are empty when absent.
parseHierarchical :: Text -> ParsedUriBody
parseHierarchical s =
  let (beforeQ, afterQ) = T.breakOn "?" s
      query = if T.null afterQ then [] else parseQuery (T.drop 1 afterQ)
      (authority, path) = case T.breakOn "/" beforeQ of
        (a, "")    -> (a, "")
        (a, slash) -> (a, slash)
  in HierarchicalBody
       { puAuthority = authority
       , puPath      = path
       , puQuery     = query
       }

-- | Parse @k1=v1&k2=v2@. No URL-decoding — keys and values are taken
-- verbatim. Missing @=@ yields an empty value.
parseQuery :: Text -> [(Text, Text)]
parseQuery s = map splitPair (T.splitOn "&" s)
  where
    splitPair pair = case T.breakOn "=" pair of
      (k, "")     -> (k, "")
      (k, eqRest) -> (k, T.drop 1 eqRest)

-- ============================================================================
-- Resolver interface
-- ============================================================================

-- | The shape resolvers implement. @IO@ because most backends
-- (keychain, filesystem, vault) are inherently effectful.
type ResolveFn = ParsedUri -> IO (Either ResolveError Text)

-- | A 'Resolver' knows its own scheme and how to dereference a URI.
--
-- Kept as a typeclass for ergonomic instance declarations, but the
-- registry stores the bare 'ResolveFn' so resolvers defined outside
-- this package (e.g. a future @plexus-vault@) compose without any
-- typeclass gymnastics.
class Resolver r where
  scheme  :: Proxy r -> Text
  resolve :: r -> ResolveFn

-- | A named resolver for debugging / diagnostics — pair the scheme
-- string with its 'ResolveFn' without going through the typeclass.
data NamedResolver = NamedResolver
  { nrScheme  :: Text
  , nrResolve :: ResolveFn
  }

-- ============================================================================
-- Registry
-- ============================================================================

-- | Map from scheme to resolver function. Construction via 'mempty'
-- yields an empty registry that always returns 'ResolveUnknownScheme'.
newtype ResolverRegistry = ResolverRegistry
  { schemes :: Map Text ResolveFn
  }

instance Semigroup ResolverRegistry where
  -- Right-biased union matches 'Map.union'\'s left-bias when read as
  -- @a <> b == a@ wins: we flip it so later registrations override.
  ResolverRegistry a <> ResolverRegistry b = ResolverRegistry (Map.union b a)

instance Monoid ResolverRegistry where
  mempty = ResolverRegistry Map.empty

-- | An empty registry. Equivalent to 'mempty'; provided for discoverability.
emptyRegistry :: ResolverRegistry
emptyRegistry = mempty

-- | Register a resolver function under a scheme. Last write wins.
registerResolver :: Text -> ResolveFn -> ResolverRegistry -> ResolverRegistry
registerResolver s fn (ResolverRegistry m) =
  ResolverRegistry (Map.insert s fn m)

-- | Register a 'NamedResolver'. Convenience for the typeclass-free path.
registerNamedResolver :: NamedResolver -> ResolverRegistry -> ResolverRegistry
registerNamedResolver NamedResolver{..} = registerResolver nrScheme nrResolve

-- | Look up the resolver for a scheme.
lookupResolver :: Text -> ResolverRegistry -> Maybe ResolveFn
lookupResolver s (ResolverRegistry m) = Map.lookup s m

-- | Resolve a 'CredentialRef' end-to-end: parse, dispatch, and report
-- either the resolved value or a structured 'ResolveError'. Parse
-- failures surface as 'ResolveParseError' so callers get a single error
-- channel.
resolveRef :: ResolverRegistry -> CredentialRef -> IO (Either ResolveError Text)
resolveRef reg ref =
  case parseUri ref of
    Left err -> pure (Left (ResolveParseError err))
    Right parsed ->
      case lookupResolver (puScheme parsed) reg of
        Nothing -> pure (Left (ResolveUnknownScheme (puScheme parsed)))
        Just fn -> fn parsed

-- ============================================================================
-- Errors
-- ============================================================================

-- | Operator-facing failure modes for credential reference resolution.
--
-- These are what @_self resolve@ (SELF-4) surfaces — structured so UIs
-- can render @"keychain item 'uscis/access_token' not found"@ instead of
-- @"error 127"@.
data ResolveError
  = ResolveUnknownScheme Text
    -- ^ No resolver registered for this scheme.
  | ResolveNotFound CredentialRef
    -- ^ Scheme resolved but the underlying item is absent
    --   (keychain miss, env var unset, file doesn\'t exist).
  | ResolveBackendError CredentialRef Text
    -- ^ Scheme resolver hit an unexpected error from its backend.
  | ResolveParseError Text
    -- ^ The reference URI itself was malformed.
  deriving stock (Show, Eq)