packages feed

ephemeral-pg-0.3.0.0: src/EphemeralPg/Internal/Instance.hs

-- | Private, persistent ownership records. Lock paths are never recycled.
module EphemeralPg.Internal.Instance
  ( InstanceLease,
    Record (..),
    registerInstance,
    releaseInstance,
    registryFor,
    withRegistry,
    claimInstance,
    readRecord,
    recordPath,
    safeDirectory,
    safeFile,
    sameFile,
    boundedRead,
  )
where

import Control.Exception (IOException, bracket, catch, finally, mask_, onException)
import Control.Monad (unless)
import Data.Bits ((.&.))
import Data.ByteString.Char8 qualified as BS
import Data.IORef
import System.Directory qualified as D
import System.FileLock
import System.FilePath
import System.IO (IOMode (ReadMode), withBinaryFile)
import System.IO.Error (isAlreadyExistsError, isDoesNotExistError)
import System.IO.Temp (createTempDirectory)
import System.Posix.Directory qualified as Posix
import System.Posix.Files
import System.Posix.Process (getProcessID)
import System.Posix.Types (ProcessID)
import System.Posix.User (getEffectiveUserID)
import Text.Read (readMaybe)

data Record = Record {version :: Int, dataPath :: FilePath, owner :: ProcessID, temporary :: Bool}
  deriving stock (Eq, Show, Read)

data InstanceLease = InstanceLease FilePath FilePath FileLock (IORef Bool)

safeDirectory :: FilePath -> IO FileStatus
safeDirectory path = do
  st <- getSymbolicLinkStatus path
  uid <- getEffectiveUserID
  unless (isDirectory st && fileOwner st == uid) $ ioError $ userError "Unsafe instance directory"
  pure st

safeFile :: FilePath -> IO FileStatus
safeFile path = do
  st <- getSymbolicLinkStatus path
  uid <- getEffectiveUserID
  unless (isRegularFile st && fileOwner st == uid && linkCount st == 1) $
    ioError $
      userError "Unsafe control file"
  pure st

sameFile :: FileStatus -> FileStatus -> Bool
sameFile a b = deviceID a == deviceID b && fileID a == fileID b

boundedRead :: FilePath -> IO String
boundedRead path = do
  before <- safeFile path
  bytes <- withBinaryFile path ReadMode $ \h -> BS.hGet h 8193
  after <- safeFile path
  unless (sameFile before after && BS.length bytes <= 8192) $ ioError $ userError "Changed or oversized control file"
  pure (BS.unpack bytes)

registryFor :: FilePath -> IO FilePath
registryFor root = do
  uid <- getEffectiveUserID
  let registry = root </> (".ephemeral-pg-instances-" <> show uid)
  Posix.createDirectory registry 0o700 `catch` \(e :: IOException) ->
    unless (isAlreadyExistsError e) (ioError e)
  st <- safeDirectory registry
  unless (fileMode st .&. 0o077 == 0) $ ioError $ userError "Instance registry must be private"
  pure registry

-- The enclosing directory is private; reject existing non-regular lock files.
checkLock :: FilePath -> IO ()
checkLock path =
  (safeFile path >> pure ()) `catch` \(e :: IOException) ->
    unless (isDoesNotExistError e) (ioError e)

withRegistry :: FilePath -> IO a -> IO a
withRegistry registry action = do
  _ <- safeDirectory registry
  let path = registry </> "registry.lock"
  checkLock path
  withFileLock path Exclusive $ \_ -> action

recordPath :: FilePath -> FilePath -> FilePath
recordPath registry path = registry </> takeFileName path <.> "record"

readRecord :: FilePath -> FilePath -> IO Record
readRecord registry path = do
  txt <- boundedRead (recordPath registry path)
  case readMaybe txt of
    Just r | r.version == 1 && r.dataPath == path && r.temporary && r.owner > 1 -> pure r
    _ -> ioError $ userError "Invalid instance record"

registerInstance :: FilePath -> IO (FilePath, InstanceLease)
registerInstance root = mask_ $ do
  registry <- registryFor root
  withRegistry registry $ do
    path <- createTempDirectory root "ephpg-data-"
    let lockPath = registry </> takeFileName path <.> "lock"
    checkLock lockPath
    acquired <- tryLockFile lockPath Exclusive
    lock <- maybe (ioError $ userError "New instance lock is busy") pure acquired
    let publish = do
          pid <- getProcessID
          let target = recordPath registry path
          writeFile (target <.> "new") (show (Record 1 path pid True))
          D.renameFile (target <.> "new") target
          ref <- newIORef False
          pure (path, InstanceLease registry path lock ref)
    publish `onException` unlockFile lock

-- Release is idempotent. Keep metadata and lock inode: a later sweep can retry
-- failed cleanup, and no waiter can accidentally acquire an obsolete inode.
releaseInstance :: InstanceLease -> IO ()
releaseInstance (InstanceLease registry path lock ref) = mask_ $ do
  released <- atomicModifyIORef' ref (\old -> (True, old))
  unless released $
    ( do
        exists <- D.doesPathExist path
        unless exists $
          withRegistry registry $
            D.removeFile (recordPath registry path) `catch` \(e :: IOException) ->
              unless (isDoesNotExistError e) (ioError e)
    )
      `finally` unlockFile lock

-- Claims are opened under the registry lock and held outside it. The record is
-- re-read by the caller while holding the lifetime lock.
claimInstance :: FilePath -> FilePath -> (Maybe FileLock -> IO a) -> IO a
claimInstance registry path = bracket acquire (mapM_ unlockFile)
  where
    acquire = withRegistry registry $ do
      let lockPath = registry </> takeFileName path <.> "lock"
      checkLock lockPath
      tryLockFile lockPath Exclusive