packages feed

heni-0.1.0.0: src/Test/Heni/Internal/Files.hs

{-# OPTIONS_HADDOCK not-home #-}

-- | Implementation detail, not an exposed module or supported import API.
-- Owns temporary paths and checked byte inventories for the POSIX implementation.
module Test.Heni.Internal.Files where

import Control.Exception
import Control.Monad (forM, forM_, unless, when)
import Crypto.Hash (Digest, SHA256, hash)
import Data.Bits ((.&.))
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as B8
import Data.List (sort)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import System.Directory qualified as Dir
import System.FilePath (takeDirectory, (</>))
import System.IO.Error (isDoesNotExistError)
import System.Posix.Files qualified as Posix
import System.Posix.Temp (mkdtemp)
import Test.Heni.Case (RelativeTarget, mkRelativeTarget, targetPath)

-- | Structured filesystem failure; ordinary IO errors remain IOException.
data WorkspaceError = UnsafeSource !Text | SourceChanged | WorkspaceMismatch !Text
  deriving stock (Eq, Show)
  deriving anyclass (Exception)

-- | Immutable bytes and executable bits, excluding ownership and timestamps.
data FileEntry = FileEntry {bytes :: !BS.ByteString, executableBits :: !Int}
  deriving stock (Eq, Show)

-- | Canonical relative targets mapped to immutable file contents.
type Inventory = Map.Map RelativeTarget FileEntry

-- | Preserve both exceptions when disposal fails after an action already failed.
data WorkspaceFinalizationError = WorkspaceFinalizationError !(Maybe SomeException) !SomeException
  deriving stock (Show)
  deriving anyclass (Exception)

-- | Mask disposal, retaining both the primary and secondary exception.
withFinalizer :: forall a. IO a -> IO () -> IO a
withFinalizer action release = mask $ \restore -> do
  primary <- try (restore action) :: IO (Either SomeException a)
  secondary <- try release :: IO (Either SomeException ())
  case (primary, secondary) of
    (Right value, Right ()) -> pure value
    (Left e, Right ()) -> throwIO e
    (Right _, Left e) -> throwIO (WorkspaceFinalizationError Nothing e)
    (Left first, Left second) -> throwIO (WorkspaceFinalizationError (Just first) second)

-- | Lowercase hexadecimal SHA-256 content digest.
digest :: BS.ByteString -> Text
digest = Text.pack . show . (hash :: BS.ByteString -> Digest SHA256)

-- | Length-prefix bytes to prevent concatenation ambiguity.
framed :: BS.ByteString -> BS.ByteString
framed b = B8.pack (show (BS.length b) ++ ":") <> b

-- | Hash policy, names, executable bits and bytes with explicit framing.
inventoryDigest :: Text -> Inventory -> Text
inventoryDigest policy files =
  digest $
    framed (Text.encodeUtf8 policy)
      <> BS.concat [framed (Text.encodeUtf8 (Text.pack (targetPath p))) <> framed (B8.pack (show mode)) <> framed b | (p, FileEntry b mode) <- Map.toAscList files]

-- | Allocate an opaque temporary directory and dispose it after the callback.
withOwnedDirectory :: String -> (FilePath -> IO a) -> IO a
withOwnedDirectory prefix action = mask $ \restore -> do
  root <- allocate
  withFinalizer (restore (action root)) (Dir.removePathForcibly root)
  where
    allocate = do
      temp <- Dir.getTemporaryDirectory >>= Dir.canonicalizePath
      mkdtemp (temp </> prefix)

-- | No symlink component is accepted, including a dangling link. Call before IO.
checkedPath :: FilePath -> RelativeTarget -> IO FilePath
checkedPath root target = do
  rootStatus <- Posix.getSymbolicLinkStatus root
  unless (Posix.isDirectory rootStatus && not (Posix.isSymbolicLink rootStatus)) (throwIO (UnsafeSource "workspace root is not a directory"))
  go root (Text.splitOn "/" (Text.pack (targetPath target)))
  pure (root </> targetPath target)
  where
    go _ [] = pure ()
    go current (part : rest) = do
      let p = current </> Text.unpack part
      status <- tryIO (Posix.getSymbolicLinkStatus p)
      case status of
        Left e | isDoesNotExistError e -> pure ()
        Left e -> throwIO e
        Right st -> do
          when (Posix.isSymbolicLink st) (throwIO (UnsafeSource ("symlink target: " <> Text.pack p)))
          unless
            (if null rest then Posix.isRegularFile st || Posix.isDirectory st else Posix.isDirectory st)
            (throwIO (UnsafeSource ("non-regular path: " <> Text.pack p)))
          go p rest

-- | Catch only ordinary IO failures, preserving asynchronous exceptions.
tryIO :: IO a -> IO (Either IOException a)
tryIO = try

-- | Read the exact selected regular-file tree; names are canonical relative paths.
inventory :: (FilePath -> Bool) -> FilePath -> IO Inventory
inventory excluded root = Map.fromList <$> walk ""
  where
    walk relative = do
      let full = if null relative then root else root </> relative
      st <- Posix.getSymbolicLinkStatus full
      when (Posix.isSymbolicLink st) (throwIO (UnsafeSource ("source symlink is unsupported: " <> Text.pack relative)))
      if Posix.isDirectory st
        then do
          names <- sort <$> Dir.listDirectory full
          concat <$> forM names (\n -> let p = if null relative then n else relative </> n in if excluded p then pure [] else walk p)
        else
          if Posix.isRegularFile st
            then do
              target <- either (throwIO . UnsafeSource . Text.pack . show) pure (mkRelativeTarget relative)
              b <- BS.readFile full
              pure [(target, FileEntry b (fromIntegral (Posix.fileMode st .&. 0o111)))]
            else throwIO (UnsafeSource ("special source file is unsupported: " <> Text.pack relative))

-- | Write independent regular files with their captured executable bits.
materialize :: FilePath -> Inventory -> IO ()
materialize root files = forM_ (Map.toAscList files) $ \(target, FileEntry b mode) -> do
  p <- checkedPath root target
  Dir.createDirectoryIfMissing True (takeDirectory p)
  BS.writeFile p b
  Posix.setFileMode p (0o600 + fromIntegral mode)