packages feed

heni-0.1.0.0: src/Test/Heni/Workspace.hs

-- | Immutable source bytes and exclusive disposable working copies.
module Test.Heni.Workspace
  ( SourceSnapshot,
    SnapshotPolicy (..),
    defaultSnapshotPolicy,
    defaultExcludedNames,
    defaultExcludedPrefixes,
    Workspace,
    WorkspaceError (..),
    WorkspaceFinalizationError (..),
    FileEntry (..),
    withSnapshot,
    withWorkspace,
    workspaceRoot,
    snapshotIdentity,
    snapshotPolicy,
    snapshotFiles,
    verifyWorkspace,
    resetWorkspace,
    checkedWorkspacePath,
    validateOutputLocation,
  )
where

import Control.Exception (throwIO)
import Control.Monad (forM_, unless, when)
import Data.List (isPrefixOf)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
import System.Directory qualified as Dir
import System.FilePath (makeRelative, splitDirectories, (</>))
import Test.Heni.Case (RelativeTarget, targetPath)
import Test.Heni.Internal.Files

-- | Includes selected working-tree bytes (dirty and untracked), never implicit HEAD.
data SnapshotPolicy = SnapshotPolicy
  { includes :: ![RelativeTarget],
    excludes :: ![RelativeTarget]
  }
  deriving stock (Eq, Show)

-- | Empty includes means the root. Default excluded names apply at every depth.
defaultSnapshotPolicy :: SnapshotPolicy
defaultSnapshotPolicy = SnapshotPolicy [] []

-- | Stored in memory: callers cannot mutate snapshot bytes through a filesystem path.
data SourceSnapshot = SourceSnapshot !Text !SnapshotPolicy !Inventory

-- | Constructor hidden: only Heni allocates and owns a cleanup target.
newtype Workspace = Workspace FilePath

-- | SHA-256 over framed sorted names, bytes, executable bits and policy.
snapshotIdentity :: SourceSnapshot -> Text
snapshotIdentity (SourceSnapshot identity _ _) = identity

-- | Effective inclusion and exclusion choices.
snapshotPolicy :: SourceSnapshot -> SnapshotPolicy
snapshotPolicy (SourceSnapshot _ policy _) = policy

-- | Immutable selected bytes, including binary fixtures.
snapshotFiles :: SourceSnapshot -> Map.Map RelativeTarget FileEntry
snapshotFiles (SourceSnapshot _ _ files) = files

-- | Exclusive directory for consumer commands. This is not an OS sandbox.
workspaceRoot :: Workspace -> FilePath
workspaceRoot (Workspace root) = root

-- | Effective default directory/file names, recorded in snapshot identities.
defaultExcludedNames :: [FilePath]
defaultExcludedNames = [".git", "dist", "dist-newstyle", "cabal-dev", ".direnv", "result", "mutation-results", ".heni", ".DS_Store"]

-- | Name prefixes excluded at every depth.
defaultExcludedPrefixes :: [FilePath]
defaultExcludedPrefixes = ["result-", ".heni-"]

excludedBy :: SnapshotPolicy -> FilePath -> Bool
excludedBy (SnapshotPolicy ins outs) p = defaultExcluded || explicitExcluded || outsideSelection
  where
    parts = splitDirectories p
    defaultExcluded = any (\part -> part `elem` defaultExcludedNames || any (`isPrefixOf` part) defaultExcludedPrefixes) parts
    prefix a b = a == b || (a ++ "/") `isPrefixOf` b
    explicitExcluded = any (\t -> prefix (targetPath t) p) outs
    outsideSelection = not (null ins) && not (any (\t -> prefix p (targetPath t) || prefix (targetPath t) p) ins)

-- | Capture twice and require identical inventory; this detects ordinary concurrent
-- changes, but is not an atomic OS snapshot under adversarial writers.
withSnapshot :: FilePath -> SnapshotPolicy -> (SourceSnapshot -> IO a) -> IO a
withSnapshot root policy action = do
  canonical <- Dir.canonicalizePath root
  when (canonical == "/" || canonical == "/nix/store" || "/nix/store/" `isPrefixOf` canonical) (throwIO (UnsafeSource "source root is not a project directory"))
  symlink <- Dir.pathIsSymbolicLink root
  when symlink (throwIO (UnsafeSource "source root is a symlink"))
  let SnapshotPolicy {includes} = policy
  forM_ includes $ \target -> do
    path <- checkedPath canonical target
    exists <- Dir.doesPathExist path
    unless exists (throwIO (UnsafeSource ("included input does not exist: " <> Text.pack (targetPath target))))
  before <- inventory (excludedBy policy) canonical
  after <- inventory (excludedBy policy) canonical
  unless (before == after) (throwIO SourceChanged)
  action (SourceSnapshot (inventoryDigest (Text.pack (show policy ++ show defaultExcludedNames ++ show defaultExcludedPrefixes)) before) policy before)

-- | Bracket allocation and removal. Cleanup exceptions remain observable.
withWorkspace :: SourceSnapshot -> (Workspace -> IO a) -> IO a
withWorkspace (SourceSnapshot _ _ files) action = withOwnedDirectory ".heni-workspace-" $ \root -> do
  materialize root files
  action (Workspace root)

-- | Verify exact source inventory. Build outputs are deliberately excluded.
verifyWorkspace :: SourceSnapshot -> Workspace -> IO ()
verifyWorkspace (SourceSnapshot _ policy files) (Workspace root) = do
  actual <- inventory (excludedBy policy) root
  unless (actual == files) (throwIO (WorkspaceMismatch "workspace differs from pristine snapshot"))

-- | Reset only an opaque owned workspace, then independently verify it.
-- A thrown exception invalidates reuse; the caller must allocate a fresh copy.
resetWorkspace :: SourceSnapshot -> Workspace -> IO ()
resetWorkspace snap@(SourceSnapshot _ _ files) (Workspace root) = do
  -- Validate the root before listing or deleting anything.
  _ <- inventory (const True) root
  names <- Dir.listDirectory root
  mapM_ (Dir.removePathForcibly . (root </>)) names
  materialize root files
  verifyWorkspace snap (Workspace root)

-- | Validate every existing component before an operation; rejects symlink escapes.
checkedWorkspacePath :: Workspace -> RelativeTarget -> IO FilePath
checkedWorkspacePath (Workspace root) = checkedPath root

-- | Output under the source root is permitted only when the effective policy
-- excludes it. Ancestors and source-equal locations are always rejected.
validateOutputLocation :: FilePath -> SnapshotPolicy -> FilePath -> IO ()
validateOutputLocation source policy output = do
  s <- Dir.canonicalizePath source
  o <- Dir.canonicalizePath output
  let relative = makeRelative s o
      outputAncestor = o == s || (o ++ "/") `isPrefixOf` s
      outputInside = not (null relative) && not (".." `elem` splitDirectories relative) && not ("/" `isPrefixOf` relative)
  when
    (outputAncestor || (outputInside && not (excludedBy policy relative)))
    (throwIO (UnsafeSource "output must be outside the source or explicitly excluded"))