heni-0.1.0.0: src/Test/Heni/BuildReuse.hs
-- | Explicit build-tool contracts for retaining private worker artifacts.
-- Generic commands have no automatic invalidation guarantee and default fresh.
module Test.Heni.BuildReuse
( BuildReuse (..),
BuildWorker,
BuildReuseError (..),
validateBuildReuse,
withBuildWorker,
withBuildCandidate,
confirmBuild,
retireBuildWorker,
)
where
import Control.Exception
import Control.Monad (forM_, unless, when)
import Data.IORef
import Data.Text (Text)
import Data.Text qualified as Text
import System.Directory qualified as Dir
import System.FilePath (splitDirectories, (</>))
import Test.Heni.Case
import Test.Heni.Internal.Files
import Test.Heni.Workspace
-- | A consumer promises a content-key-aware build tool. Before tests, the
-- verifier must prove that the completed build observed HENI_BUILD_INPUT_ID.
-- The preparation callback can enforce conservative timestamp behavior. Neither
-- callback is an attestation against malicious commands. This hook is Haskell
-- only because arbitrary build tools cannot share one reliable portable check.
data BuildReuse = BuildReuse
{ contractIdentity :: !Text,
retainedDirectories :: ![RelativeTarget],
prepareInputs :: !(Workspace -> IO ()),
verifyBuildObservation :: !(Workspace -> Text -> IO (Either Text ()))
}
-- | Invalidation/reset failures invalidate the current case, never a kill.
newtype BuildReuseError = BuildReuseError Text
deriving stock (Eq, Show)
deriving anyclass (Exception)
-- | One private workspace and an in-memory command/environment contract.
data BuildWorker key = BuildWorker !SourceSnapshot !BuildReuse !Workspace !(IORef (Maybe (key, Inventory))) !(IORef Bool)
-- | Retain only known top-level build directories, never selected source paths.
validateBuildReuse :: BuildReuse -> Either ConfigError ()
validateBuildReuse BuildReuse {contractIdentity, retainedDirectories} = do
unless (not (Text.null (Text.strip contractIdentity)) && not (null retainedDirectories)) (Left (ConfigError "buildReuse" "nonempty contract and build directories required"))
unless (all (\p -> targetPath p `elem` ["dist", "dist-newstyle", ".heni-build"]) retainedDirectories) (Left (ConfigError "buildReuse.directories" "only dist, dist-newstyle or .heni-build can be retained"))
-- | The workspace is never shared with another worker and is discarded at exit.
withBuildWorker :: SourceSnapshot -> BuildReuse -> (BuildWorker key -> IO a) -> IO a
withBuildWorker snapshot provider action = withWorkspace snapshot $ \workspace -> do
previous <- newIORef Nothing
dirty <- newIORef True
action (BuildWorker snapshot provider workspace previous dirty)
-- | Retire retained artifacts after any suspicious lifecycle result.
retireBuildWorker :: BuildWorker key -> IO ()
retireBuildWorker (BuildWorker _ _ _ _ dirty) = writeIORef dirty True
-- | Independently verify the previous source tree, restore exact staged bytes,
-- preserve private build directories, and verify source again after use. A
-- retired worker starts by deleting all retained artifacts from its owned tree.
withBuildCandidate :: (Eq key) => BuildWorker key -> key -> Workspace -> (Workspace -> Text -> IO a) -> IO a
withBuildCandidate worker@(BuildWorker snapshot BuildReuse {retainedDirectories, prepareInputs} workspace previous dirty) key staged action =
operation `onException` retireBuildWorker worker
where
names = map targetPath retainedDirectories
excluded path = case splitDirectories path of first : _ -> first `elem` names; [] -> False
operation = do
old <- readIORef previous
retired <- readIORef dirty
when (retired || maybe True ((/= key) . fst) old) (resetWorkspace snapshot workspace >> writeIORef previous Nothing)
saved <- readIORef previous
forM_ saved $ \(_, expected) ->
inventory excluded (workspaceRoot workspace) >>= \actual ->
unless (actual == expected) (throwIO (BuildReuseError "unexpected source/generated state in retained worker"))
expected <- inventory (const False) (workspaceRoot staged)
entries <- Dir.listDirectory (workspaceRoot workspace)
forM_ (filter (`notElem` names) entries) (Dir.removePathForcibly . (workspaceRoot workspace </>))
materialize (workspaceRoot workspace) expected
actual <- inventory excluded (workspaceRoot workspace)
unless (actual == expected) (throwIO (BuildReuseError "restored build inputs do not match staged mutation"))
let inputId = inventoryDigest "heni-build-input-v1" expected
prepareInputs workspace
verified <- inventory excluded (workspaceRoot workspace)
unless (verified == expected) (throwIO (BuildReuseError "build preparation changed selected input bytes"))
value <- action workspace inputId
final <- inventory excluded (workspaceRoot workspace)
unless (final == expected) (throwIO (BuildReuseError "build/test changed source or created undeclared generated inputs"))
writeIORef previous (Just (key, expected))
writeIORef dirty False
pure value
-- | Check the build-tool observation before running tests against its artifacts.
confirmBuild :: BuildWorker key -> Text -> IO ()
confirmBuild (BuildWorker _ BuildReuse {verifyBuildObservation} workspace _ _) inputId =
verifyBuildObservation workspace inputId >>= either (throwIO . BuildReuseError) pure