plexus-synapse-3.15.0: src/Synapse/Self/IO.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | IO glue for the per-backend defaults store (SELF-2).
--
-- Three pieces live here:
--
-- * 'loadDefaults' — read @~\/.plexus\/\<backend\>\/defaults.json@ from disk.
-- * 'resolveAll' — dispatch every 'CredentialRef' in a 'StoredDefaults'
-- through a 'ResolverRegistry', yielding a
-- 'ResolvedDefaults' with concrete values.
-- * 'merge' — pure left-biased merge of CLI-provided cookies\/headers
-- over the resolved stored defaults. CLI wins per key.
--
-- The three compose into the read path that synapse\'s request builder uses
-- on every invocation: load → resolve → merge → hand to the upgrade.
--
-- == Platform notes
--
-- POSIX mode semantics (@chmod 0600@ on sensitive files, @0700@ on the
-- parent dir, permission-sniffing on read) apply only on Unix. On
-- Windows ('System.Info.os' == @"mingw32"@) those steps are skipped —
-- the atomic temp-file + rename dance still happens, but permission
-- enforcement is a platform gap tracked by the SELF-5 ticket's \"Out of
-- scope\" list (no ACL story yet).
module Synapse.Self.IO
( -- * Resolved defaults (concrete values, not refs)
ResolvedDefaults(..)
, emptyResolvedDefaults
-- * Aliases used by the pipeline
, MethodPath
, Cookies
, Headers
-- * Load / resolve / merge
, loadDefaults
, resolveAll
, merge
-- * Write path (atomic rename + mode-aware chmod; SELF-5)
, writeDefaults
, absDefaultsPath
) where
import Control.Exception (IOException, bracketOnError, throwIO, try)
import qualified Control.Exception as E
import qualified Data.ByteString as BS
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.Directory
( createDirectoryIfMissing
, doesDirectoryExist
, doesFileExist
, getHomeDirectory
, removeFile
, renameFile
)
import System.FilePath ((</>), takeDirectory)
import System.IO (hPutStrLn, stderr)
import System.IO.Error (ioeGetErrorString, isDoesNotExistError, userError)
import qualified System.Info
import System.Posix.Files (FileStatus, fileMode, getFileStatus, setFileMode)
import System.Posix.Types (FileMode)
import Data.Bits ((.&.))
import Synapse.Self.Resolve
( ResolveError(..)
, ResolverRegistry
, resolveRef
)
import Synapse.Self.Types
( CredentialRef(..)
, StoredDefaults(..)
, decodeDefaults
, encodeDefaults
, emptyStoredDefaults
)
-- ============================================================================
-- Types used by the request-building pipeline
-- ============================================================================
-- | A method path. v1 ignores the contents entirely — it exists so SELF-7+
-- can introduce scope matching without a signature change.
type MethodPath = [Text]
-- | Cookies attached to the WebSocket upgrade, keyed by name.
type Cookies = Map Text Text
-- | Extra HTTP headers attached to the upgrade, keyed by name.
type Headers = Map Text Text
-- | A 'StoredDefaults' after every 'CredentialRef' has been dereferenced
-- through a 'ResolverRegistry'. Parallels the on-disk shape but carries
-- concrete 'Text' values instead of refs.
data ResolvedDefaults = ResolvedDefaults
{ rdCookies :: !Cookies
, rdHeaders :: !Headers
}
deriving stock (Show, Eq)
-- | Empty resolved defaults — what 'resolveAll' returns for
-- 'emptyStoredDefaults'.
emptyResolvedDefaults :: ResolvedDefaults
emptyResolvedDefaults = ResolvedDefaults
{ rdCookies = Map.empty
, rdHeaders = Map.empty
}
-- ============================================================================
-- loadDefaults
-- ============================================================================
-- | Compute the absolute path to a backend's defaults file, expanding @~@
-- via 'getHomeDirectory'. 'Synapse.Self.defaultsPath' returns the
-- tilde-prefixed form for pure code; this is the IO-side expansion.
absDefaultsPath :: Text -> IO FilePath
absDefaultsPath backend = do
home <- getHomeDirectory
pure (home </> ".plexus" </> T.unpack backend </> "defaults.json")
-- | Read a backend's defaults file.
--
-- * Missing file → 'emptyStoredDefaults' (NOT an error — absence means
-- "no defaults configured for this backend").
-- * Parse failure → 'IOException' naming the file path and the decoder's
-- message.
-- * Other IO errors (permission denied, …) → propagate as-is so the caller
-- gets a clear platform error.
--
-- __SELF-5:__ after a successful decode, inspect the file contents and
-- its Unix mode. If the encoded bytes contain a @literal:@ substring
-- (i.e. a raw credential value lives in the file) AND the mode has any
-- group- or world-accessible bits set (@mode .&. 0o077 \/= 0@), emit a
-- stderr WARN line recommending @chmod 600 \<path\>@. The read still
-- succeeds — the warning is a nudge, not a failure.
--
-- Files that contain only @keychain:\/\/@ \/ @env:\/\/@ \/ @file:\/\/@
-- refs are strictly a manifest and are safe at any mode; no warning is
-- emitted for those regardless of permissions. On Windows the mode
-- check is skipped entirely (no POSIX mode semantics).
loadDefaults :: Text -> IO StoredDefaults
loadDefaults backend = do
-- SELF-3: on entry, migrate the legacy ~/.plexus/tokens/<backend> file
-- to the new defaults.json (or delete it if it's stale / empty). After
-- migration completes (or is a no-op), the body below reads the new
-- file naturally — no double-work for the caller.
migrateIfNeeded backend
path <- absDefaultsPath backend
exists <- doesFileExist path
if not exists
then pure emptyStoredDefaults
else do
result <- try (BS.readFile path) :: IO (Either IOException BS.ByteString)
case result of
Left ioErr
| isDoesNotExistError ioErr -> pure emptyStoredDefaults
| otherwise ->
throwIO $ userError $
"loadDefaults: failed to read " <> path
<> ": " <> ioeGetErrorString ioErr
Right bs -> case decodeDefaults bs of
Left err ->
throwIO $ userError $
"loadDefaults: failed to parse " <> path
<> ": " <> T.unpack err
Right sd -> do
warnIfInsecureMode path bs
pure sd
-- ============================================================================
-- migrateIfNeeded (SELF-3: legacy ~/.plexus/tokens/<backend> → defaults.json)
-- ============================================================================
-- | Compute the absolute path to the legacy per-backend token file
-- (@~\/.plexus\/tokens\/\<backend\>@).
--
-- This is the /only/ call site in synapse that references
-- @~\/.plexus\/tokens\/@. Every other read-path has been routed through
-- 'loadDefaults'; the legacy path lives here solely so first-boot
-- migration can drain it. Grep enforcement: after SELF-3,
-- @git grep -n 'plexus\/tokens'@ should return zero hits outside this
-- function (and the ticket's own plans\/ docs).
legacyTokenPath :: Text -> IO FilePath
legacyTokenPath backend = do
home <- getHomeDirectory
pure (home </> ".plexus" </> "tokens" </> T.unpack backend)
-- | First-boot migration of the legacy plaintext token file into a
-- @defaults.json@ manifest with a @literal:\<jwt\>@ ref.
--
-- Runs at the top of every 'loadDefaults' invocation but is effectively
-- a no-op in steady state (fast path: the legacy file does not exist).
-- Three cases:
--
-- 1. New file absent, legacy absent → nothing to do.
-- 2. New file present, legacy present → legacy is stale; delete it
-- unread and leave the new file untouched. Log INFO.
-- 3. New file absent, legacy present → migrate:
-- * Read the legacy file; trim leading\/trailing whitespace.
-- * If the trimmed contents are empty: delete legacy silently and
-- return (the existing 'loadDefaults' body then sees no file and
-- returns 'emptyStoredDefaults').
-- * Otherwise: construct a 'StoredDefaults' with
-- @cookies.access_token = CredentialRef "literal:\<jwt\>"@,
-- write it via 'writeDefaults' (SELF-5 atomic + 0600 chmod),
-- delete the legacy file, and log INFO suggesting
-- @synapse _self \<backend\> upgrade-to-keychain@.
--
-- Security note: migration target is deliberately @literal:@, not
-- @keychain:\/\/@. A silent permission escalation would surprise users
-- and could trigger an OS keychain prompt. Opt-in upgrade is the
-- @_self \<backend\> upgrade-to-keychain@ verb.
migrateIfNeeded :: Text -> IO ()
migrateIfNeeded backend = do
newPath <- absDefaultsPath backend
legacyPath <- legacyTokenPath backend
newExists <- doesFileExist newPath
legacyExists <- doesFileExist legacyPath
case (newExists, legacyExists) of
(_, False) -> pure () -- no legacy → nothing to do
(True, True) -> do
-- Stale legacy file: new defaults.json wins. Delete the legacy
-- file unread so it doesn't masquerade as authoritative state.
E.handle (\(_ :: IOException) -> pure ()) (removeFile legacyPath)
hPutStrLn stderr $
"[INFO] removed stale legacy token file " <> legacyPath
<> " (superseded by defaults.json)"
(False, True) -> do
-- Real migration. Read, trim, decide empty vs non-empty.
readResult <- try (BS.readFile legacyPath)
:: IO (Either IOException BS.ByteString)
case readResult of
Left _ -> pure () -- unreadable legacy file: leave it; next run retries
Right raw -> do
let trimmed = T.strip (TE.decodeUtf8 raw)
if T.null trimmed
then
-- Empty / whitespace-only legacy: drop it silently. No
-- new file is created — the existing 'loadDefaults' body
-- returns 'emptyStoredDefaults'.
E.handle (\(_ :: IOException) -> pure ()) (removeFile legacyPath)
else do
let sd = emptyStoredDefaults
{ sdCookies = Map.singleton "access_token"
(CredentialRef ("literal:" <> trimmed))
}
writeDefaults backend sd
E.handle (\(_ :: IOException) -> pure ()) (removeFile legacyPath)
hPutStrLn stderr $
"[INFO] migrated legacy token file " <> legacyPath
<> " to " <> newPath
<> " (stored as literal:). Consider: synapse _self "
<> T.unpack backend <> " upgrade-to-keychain"
-- | Emit a stderr WARN when the file contains a @literal:@ value AND
-- its mode is group-\/world-accessible. Silent on Windows (mode is
-- meaningless) and on non-literal-only manifests (safe at any mode).
--
-- A failure to stat the file (e.g. race with deletion) is swallowed —
-- the caller has already read the bytes, so there is no state to
-- recover from here.
warnIfInsecureMode :: FilePath -> BS.ByteString -> IO ()
warnIfInsecureMode path bs
| System.Info.os == "mingw32" = pure ()
| not (BS.isInfixOf "literal:" bs) = pure ()
| otherwise = do
mSt <- try (getFileStatus path) :: IO (Either IOException FileStatus)
case mSt of
Left _ -> pure ()
Right st -> do
let m = fileMode st
if m .&. (0o077 :: FileMode) /= 0
then hPutStrLn stderr $
"[WARN] " <> path <> " contains literal: credentials "
<> "and is group/world accessible; recommend `chmod 600 "
<> path <> "`."
else pure ()
-- ============================================================================
-- resolveAll
-- ============================================================================
-- | Resolve every 'CredentialRef' in a 'StoredDefaults' through the
-- registry. Dispatch is sequential: the first failure short-circuits and
-- the partial map is discarded — we never send a half-authenticated
-- request.
--
-- The 'MethodPath' is threaded unchanged for forward compatibility with
-- scope matching. v1 ignores it; backend-level defaults are the only ones
-- consulted.
--
-- Returns 'Right' with a fully-populated 'ResolvedDefaults' on success,
-- 'Left' with the first 'ResolveError' encountered on failure.
resolveAll
:: ResolverRegistry
-> StoredDefaults
-> MethodPath
-> IO (Either ResolveError ResolvedDefaults)
resolveAll reg StoredDefaults{..} _methodPath = do
eCookies <- resolveMap reg sdCookies
case eCookies of
Left err -> pure (Left err)
Right cookies -> do
eHeaders <- resolveMap reg sdHeaders
case eHeaders of
Left err -> pure (Left err)
Right headers -> pure $ Right ResolvedDefaults
{ rdCookies = cookies
, rdHeaders = headers
}
-- | Resolve every value in a @Map Text CredentialRef@, short-circuiting
-- on the first failure. Preserves input keys.
resolveMap
:: ResolverRegistry
-> Map Text CredentialRef
-> IO (Either ResolveError (Map Text Text))
resolveMap reg = go . Map.toAscList
where
go [] = pure (Right Map.empty)
go ((k, ref) : rest) = do
r <- resolveRef reg ref
case r of
Left err -> pure (Left err)
Right v -> do
rest' <- go rest
case rest' of
Left err -> pure (Left err)
Right m -> pure $ Right (Map.insert k v m)
-- ============================================================================
-- merge
-- ============================================================================
-- | Pure left-biased merge of CLI cookies\/headers over the resolved
-- stored defaults. CLI wins per key.
--
-- The caller is responsible for wrapping each CLI @--cookie KEY=VALUE@ and
-- @--header KEY=VALUE@ into the 'Cookies'\/'Headers' maps before calling
-- 'merge'; there is no special casing for the source of a value inside
-- this function.
merge
:: ResolvedDefaults
-> Cookies -- ^ from CLI flags (wins on conflict)
-> Headers -- ^ from CLI flags (wins on conflict)
-> (Cookies, Headers)
merge ResolvedDefaults{..} cliCookies cliHeaders =
( Map.union cliCookies rdCookies
, Map.union cliHeaders rdHeaders
)
-- ============================================================================
-- writeDefaults (SELF-5: atomic rename + mode-aware chmod)
-- ============================================================================
-- | Write a 'StoredDefaults' to disk for @backend@.
--
-- The write is atomic and mode-aware:
--
-- 1. Ensure the parent directory exists. If it was freshly created by
-- this call, chmod it to @0700@. If it already existed with
-- group-\/world-accessible bits set, emit a stderr WARN but leave it
-- alone (the user may have structure we shouldn\'t disturb).
-- 2. Encode the 'StoredDefaults' to JSON.
-- 3. Write the encoded bytes to a sibling temp file
-- @\<dir\>\/.defaults.json.tmp@ (hidden dotfile so visually
-- distinct if ever stuck).
-- 4. If the encoded content contains the substring @"literal:"@ anywhere,
-- chmod the temp file to @0600@ before rename. Otherwise @0644@ is
-- acceptable (manifest-only, no secrets in-file).
-- 5. Atomically 'renameFile' the temp into place — this wraps the
-- platform @rename(2)@ syscall and is the only step that makes the
-- write visible to other readers.
-- 6. On any failure at steps 3-5, remove the temp file (if it exists)
-- and propagate the original exception. No partial @defaults.json@
-- is ever left behind.
--
-- On Windows (@System.Info.os == "mingw32"@) the chmod steps are
-- skipped; the atomic rename still happens.
writeDefaults :: Text -> StoredDefaults -> IO ()
writeDefaults backend sd = do
path <- absDefaultsPath backend
let dir = takeDirectory path
tmpPath = dir </> ".defaults.json.tmp"
bytes = encodeDefaults sd
sensitive = BS.isInfixOf "literal:" bytes
-- Step 1: parent dir. Track whether we created it so we only chmod
-- freshly-created dirs (existing dirs with wide mode get a WARN).
dirPreexisted <- doesDirectoryExist dir
createDirectoryIfMissing True dir
if not dirPreexisted
then setFileModeUnix dir 0o700
else warnIfWideDir dir
-- Steps 2-5: atomic temp write + chmod + rename. `bracketOnError`
-- removes the temp file on any exception from the write or rename;
-- on success no cleanup runs.
bracketOnError
(pure tmpPath)
(\p -> E.handle (\(_ :: IOException) -> pure ()) (removeFileIfExists p))
(\p -> do
BS.writeFile p bytes
setFileModeUnix p (if sensitive then 0o600 else 0o644)
renameFile p path)
-- | Remove a file, ignoring "does not exist" errors but propagating
-- anything else. Used by the 'bracketOnError' cleanup — we don\'t want
-- cleanup-of-nonexistent-tmp to hide the real failure.
removeFileIfExists :: FilePath -> IO ()
removeFileIfExists p = do
exists <- doesFileExist p
if exists then removeFile p else pure ()
-- | 'setFileMode' on Unix, no-op on Windows. Matches the platform gating
-- in 'warnIfInsecureMode' / 'warnIfWideDir'.
setFileModeUnix :: FilePath -> FileMode -> IO ()
setFileModeUnix p m
| System.Info.os == "mingw32" = pure ()
| otherwise = setFileMode p m
-- | WARN when a pre-existing parent dir has group-\/world-accessible
-- bits. We never tighten an existing dir\'s mode — users may have
-- structure under @~\/.plexus@ we shouldn\'t perturb. Silent on
-- Windows and on already-tight dirs.
warnIfWideDir :: FilePath -> IO ()
warnIfWideDir dir
| System.Info.os == "mingw32" = pure ()
| otherwise = do
mSt <- try (getFileStatus dir) :: IO (Either IOException FileStatus)
case mSt of
Left _ -> pure ()
Right st -> do
let m = fileMode st
if m .&. (0o077 :: FileMode) /= 0
then hPutStrLn stderr $
"[WARN] " <> dir <> " has group/world access bits set; "
<> "recommend `chmod 700 " <> dir <> "` to tighten."
else pure ()