tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

commit 6b4aabd9ec87d203e2e56a97b2878115e2e68000
parent 93f648715f4fbe4278a773dbe92b70d5637bd408
Author: James Eversole <james@eversole.co>
Date:   Wed, 14 Oct 2026 19:39:09 -0500

Extract shared portable compiler supervision from CLI orchestration

Diffstat:
Mdocs/compiler.md | 14++++++++++++++
Mdocs/validation.md | 23+++++++++++++++++++++++
Msrc/ContentStore/Executable.hs | 20++++++++++++++++----
Msrc/ContentStore/Filesystem.hs | 9++++++++-
Msrc/Main.hs | 150++++++-------------------------------------------------------------------------
Asrc/PortableCompiler.hs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/ExecutableLoadingTests.hs | 6++++++
Atest/PortableCompilerTests.hs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/Spec.hs | 4+++-
Mtricu.cabal | 3+++
10 files changed, 361 insertions(+), 146 deletions(-)

diff --git a/docs/compiler.md b/docs/compiler.md @@ -190,6 +190,20 @@ library tests may instead apply published trees directly. The `ast` display format renders tree constructors directly, without constructing source ASTs. +`ContentStore.Executable` shares authenticated flat/indexed loading across host +entrypoints. Object loading checks kind, identity and encoding; named export +loading additionally authenticates the manifest and checks the export ABI. +Whole-interface and selected-export loads authenticate the manifest once. The +streaming flat-bundle packer remains separate: it must not reconstruct expanded +trees just to reuse the executable loader. + +`PortableCompiler` supervises tool selection, loading, deadlines, restricted IO +and publication verification for file compilation and interactive sessions. It +owns no source semantics. Its failures are IO errors; CLI and REPL boundaries +choose how to report them rather than library code terminating the process. +REPL startup authenticates the selected worker under the compiler deadline, +without applying it. Explicit toolchains never fall back to packaged data. + `Runtime.Tree.T` has lazy constructor fields, and `Runtime.Tree.apply` implements the kernel rules without an explicit application/thunk node. Pending reductions live in Haskell thunks. Constructor application returns an outer Stem/Fork without diff --git a/docs/validation.md b/docs/validation.md @@ -85,6 +85,29 @@ The compiled-source smoke example is in It checks the native compiler-to-inspector path separately from independent wire fixtures; inspection itself never loads a compiler or executes an exported tree. +### Host loading and compiler supervision + +The default Haskell tests directly exercise the production loader with flat and +indexed objects: digest and ABI rejection, malformed and missing objects, +manifest rejection, retained sharing, profiling-stage order, and selected exports +that do not read unrelated objects. Whole-interface loading retains manifest order. + +Supervisor tests use constructed ABI workers, not fresh compiler bootstraps. +They check explicit selection, option validation, Result and publication +validation, source-write denial, and a deadline that interrupts a sleeping worker. +The default-frontend and portable-CLI probes additionally exercise real source +compilation, REPL transactions, cache identities and compiler profiling: + +```sh +nix build .# +python3 test/executable_arguments.py result/bin/tricu +python3 test/default_frontend.py result/bin/tricu +python3 test/portable_cli.py result/bin/tricu \ + --compiler-bundle bootstrap/build-stable.arboricx +``` + +These checks do not regenerate the compiler or publish fixtures. + ### Current-source IO dependencies ```sh diff --git a/src/ContentStore/Executable.hs b/src/ContentStore/Executable.hs @@ -2,7 +2,7 @@ -- only a module export supplies an ABI. Failures are IO errors, not process exits. module ContentStore.Executable ( loadVerifiedManifest, loadVerifiedExport, loadVerifiedExportWith - , loadVerifiedExports, loadExecutableObject ) where + , loadVerifiedExports, loadVerifiedExportSelection, loadExecutableObject ) where import ContentStore.Object import ContentStore.Filesystem (getObject) @@ -38,10 +38,22 @@ loadVerifiedExportWith sample store manifestHash name = do loadVerifiedExports :: StorePath -> T.Text -> IO [(String, Tree.T)] loadVerifiedExports store pin = do + exports <- loadVerifiedExportSelection store pin Nothing + pure [(T.unpack name, term) | (name, term) <- exports] + +-- Nothing selects the complete interface in manifest order. An explicit list +-- loads only those names, in caller order; unrelated export objects stay unread. +loadVerifiedExportSelection :: StorePath -> T.Text -> Maybe [T.Text] -> IO [(T.Text, Tree.T)] +loadVerifiedExportSelection store pin selection = do manifest <- loadVerifiedManifest store pin - forM (moduleManifestExports manifest) $ \ex -> do - (_, term) <- loadModuleExport (const $ pure ()) store ex - pure (T.unpack (moduleExportName ex), term) + let sample = const $ pure () + case selection of + Nothing -> forM (moduleManifestExports manifest) $ \ex -> do + (_, term) <- loadModuleExport sample store ex + pure (moduleExportName ex, term) + Just names -> forM names $ \name -> do + (_, term) <- loadManifestExport sample store manifest name + pure (name, term) -- Internal: the manifest must have passed authentication and decoding above. loadManifestExport :: (String -> IO ()) -> StorePath -> ModuleManifest -> T.Text -> IO (ObjectHash, Tree.T) diff --git a/src/ContentStore/Filesystem.hs b/src/ContentStore/Filesystem.hs @@ -3,6 +3,7 @@ module ContentStore.Filesystem , getVerifiedObject , getObject , objectPath + , resolveStorePath , ensureStore ) where @@ -10,7 +11,7 @@ import ContentStore.Object import Control.Monad (unless, when) import Data.Text (unpack) -import System.Directory (createDirectoryIfMissing, doesFileExist, removeFile, renameFile) +import System.Directory (getHomeDirectory, createDirectoryIfMissing, doesFileExist, removeFile, renameFile) import System.FilePath ((</>)) import System.IO (hClose, openBinaryTempFile) @@ -73,3 +74,9 @@ getVerifiedObject store domain h = do Just bytes -> do either fail pure (verifyObjectBytes domain h bytes) pure (Just bytes) + +resolveStorePath :: Maybe FilePath -> IO StorePath +resolveStorePath (Just path) = return (StorePath path) +resolveStorePath Nothing = do + home <- getHomeDirectory + return (StorePath (home </> ".tricu" </> "store")) diff --git a/src/Main.hs b/src/Main.hs @@ -13,7 +13,8 @@ import System.IO.Error (ioeGetErrorString) import System.Exit (die, exitFailure) import IODriver (IOPermissions(..), runIO, YieldStats(..), runIOWithYieldStatsHook) import REPL (portableRepl) -import Runtime (T(..), EvaluatedForm(..), formatT, exportDag, apply, ofString, ofNumber, toString) +import PortableCompiler +import Runtime (T(..), EvaluatedForm(..), formatT, exportDag, apply, ofString, toString) import qualified Wire import Wire (encodeBundle, buildBundleShared, defaultExportNames, Bundle(..)) import TreeObservation (forceTreeShared) @@ -25,7 +26,7 @@ import Data.List (sortOn) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import Data.Version (showVersion) -import Paths_tricu (version, getDataFileName) +import Paths_tricu (version) import Crypto.Hash (hash, Digest, SHA256) import System.IO.Temp (withSystemTempDirectory, withTempFile) import Options.Applicative @@ -34,9 +35,9 @@ import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy as BL import qualified Data.Sequence as Seq import qualified Data.Map as Map -import System.Directory (createDirectoryIfMissing, getHomeDirectory, getCurrentDirectory, +import System.Directory (createDirectoryIfMissing, getCurrentDirectory, renameFile, removeFile, pathIsSymbolicLink, doesPathExist, doesFileExist, canonicalizePath) -import System.FilePath (takeBaseName, takeDirectory, takeExtension, (</>)) +import System.FilePath (takeBaseName, takeDirectory, (</>)) import System.Timeout (timeout) import System.IO (openBinaryTempFile, hClose, hFlush, hPutStrLn, stdout, stderr, withBinaryFile, IOMode(ReadMode)) import qualified Data.Aeson as JSON @@ -63,15 +64,6 @@ data StoredArgument | ExportArgument String deriving (Show) --- Explicit artifact selection; absence selects the packaged stable compiler. --- Tool stores are never populated by implicit source loading. -data CompilerOptions = CompilerOptions - { compilerManifest :: Maybe String - , compilerToolStore :: Maybe FilePath - , compilerSeconds :: Int - , compilerProfile :: Bool - } deriving (Show) - data TricuArgs = PackageCommand PackageArgs | ProjectCommand ProjectArgs @@ -725,13 +717,7 @@ runReplWithOptions mStore options = do putStrLn "You may exit with `CTRL+D` or the `!exit` command." -- Fail at startup, even for an empty session, if the installation is broken. store <- resolveStorePath mStore - case compilerManifest options of - Nothing -> do - _ <- packagedCompiler store - pure () - Just pin -> do - _ <- loadVerifiedExport (maybe store StorePath $ compilerToolStore options) (T.pack pin) "sourceBuildGraph" - pure () + checkPortableCompiler options store portableRepl store (\active source -> do directory <- getCurrentDirectory withTempFile directory "tricu_repl.tri" $ \path handle -> do @@ -858,16 +844,11 @@ runCompile opts = do target <- canonicalizePath out when (source == target) $ die "Bundle output must not replace its source" (store, manifestHash) <- buildPortableModule (compileCompiler opts) (compileStore opts) input - manifest <- loadVerifiedManifest store manifestHash - let selected = if null names - then map moduleExportName (moduleManifestExports manifest) - else nameTexts - when (null selected) $ die "Portable module has no exports" - when (length selected /= Map.size (Map.fromList [(name, ()) | name <- selected])) $ + when (length nameTexts /= Map.size (Map.fromList [(name, ()) | name <- nameTexts])) $ die "Duplicate --name selection" - terms <- forM selected $ \name -> do - (_, term) <- loadVerifiedExport store manifestHash name - pure (name, term) + terms <- loadVerifiedExportSelection store manifestHash + (if null names then Nothing else Just nameTexts) + when (null terms) $ die "Portable module has no exports" bundle <- buildBundleShared terms BS.writeFile out (encodeBundle bundle) @@ -967,111 +948,6 @@ runStored opts = do putStrLn "RUN_DONE" -- One digest/kind/ABI-verified boundary for runtime execution and tooling. --- Cabal records an installation-relative data directory. Nix-built binaries --- retain their data closure when invoked outside the checkout. An explicit --- tricu_datadir override is useful for relocatable distributions and diagnostics. -packagedCompilerSha256 :: String -packagedCompilerSha256 = "5d36d069562ca9b208cfd0aa8df9f0d3b5c39f8b13e070fb892e21b38e32ffb4" - -packagedCompiler :: StorePath -> IO T.Text -packagedCompiler store = do - path <- getDataFileName "bootstrap/build-stable.arboricx" - bytes <- BS.readFile path `catch` (\(err :: IOException) -> - die $ "Packaged stable compiler unavailable at " ++ path ++ ": " ++ show err) - unless (show (hash bytes :: Digest SHA256) == packagedCompilerSha256) $ - die "Packaged stable compiler SHA256 mismatch; reinstall or select --toolchain explicitly" - roots <- unpackIndexedBundleToStore store bytes - putManifest store (ModuleManifest [] - [ModuleExport name (ObjectRef (unDomain indexedTreeDomain) root) - "arboricx.abi.tree.v1" | (name, root) <- roots]) - -portableExports :: CompilerOptions -> Maybe FilePath -> FilePath -> IO [(String, Runtime.T)] -portableExports options selected path = do - (store, pin) <- buildPortableModule options selected path - loadVerifiedExports store pin - -validateCompilerOptions :: CompilerOptions -> IO () -validateCompilerOptions options = do - when (compilerManifest options == Nothing && compilerToolStore options /= Nothing) $ - die "--tool-store requires --toolchain" - unless (compilerSeconds options >= 1 && compilerSeconds options <= 86400) $ - die "--compile-timeout must be between 1 and 86400 seconds" - --- Native flat-workspace profile: the selected file supplies the directory and --- target name; local import/re-export edges select NAME.tri within that explicit base. --- All header staging, scope, compilation, publication and cache policy is tricu. -buildPortableModule :: CompilerOptions -> Maybe FilePath -> FilePath -> IO (StorePath, T.Text) -buildPortableModule options appStore input = do - (store,manifest,_) <- buildPortableResult options appStore input - pure (store,manifest) - --- Keep the compiler-returned receipt available to reproduction tooling instead --- of recovering execution evidence from a mutable cache locator afterward. -buildPortableResult :: CompilerOptions -> Maybe FilePath -> FilePath -> IO (StorePath, T.Text, T.Text) -buildPortableResult options appStore input = do - when (compilerProfile options) $ do - enabled <- getRTSStatsEnabled - unless enabled $ die "--compile-profile requires +RTS -T" - let sample label = when (compilerProfile options) (compilerSample label) - sample "begin" - source <- canonicalizePath input - unless (takeExtension source == ".tri") $ die "Portable compilation requires a .tri module file" - store0 <- resolveStorePath appStore - createDirectoryIfMissing True (unStorePath store0) - root <- canonicalizePath (unStorePath store0) - let store = StorePath root - directory = takeDirectory source - name = takeBaseName source - tools <- maybe (pure store) (pure . StorePath) (compilerToolStore options) - built <- timeout (compilerSeconds options * 1000000) $ do - pin <- case compilerManifest options of - Just selected -> pure (T.pack selected) - Nothing -> packagedCompiler tools - (identity, worker) <- loadVerifiedExportWith sample tools pin "sourceBuildGraph" - let compilerIdentity = case compilerManifest options of - Nothing -> packagedCompilerSha256 - Just _ -> T.unpack identity - action = foldl apply worker (map ofString - [directory, root, compilerIdentity, name]) - permissions = IOPermissions [directory, root] [root] False False - -- sourceBuildGraph returns a bare v1 action; this is transport framing, - -- not source compilation or a source-loaded IO adapter. - let envelope = Fork (ofString "tricuIO") (Fork (ofNumber 1) action) - output <- runIO permissions envelope >>= either (die . ("Compiler IO error: " ++)) pure - sample "build-returned" - case output of - Fork (Stem Leaf) (Fork (Fork manifest receipt) _) -> do - manifestHash <- either (die . ("Invalid compiler manifest output: " ++)) (pure . T.pack) - (toString manifest) - receiptHash <- either (die . ("Invalid compiler receipt output: " ++)) (pure . T.pack) - (toString receipt) - _ <- loadVerifiedManifest store manifestHash - either (die . ("Invalid compiler receipt hash: " ++)) pure (textToHashBytes receiptHash) - receiptBytes <- getObject store receiptHash >>= maybe (die "Compiler receipt missing") pure - unless (hashObject (Domain "arboricx.build-receipt.v1") receiptBytes == receiptHash) $ - die "Compiler receipt digest mismatch" - pure (manifestHash,receiptHash) - Fork Leaf (Fork problem _) -> die $ "Portable compilation failed: " ++ formatT Decode problem - _ -> die "Invalid portable compiler Result" - (manifest,receipt) <- maybe (die "Portable compiler load/build deadline exceeded") pure built - sample "publication-verified" - pure (store, manifest,receipt) - --- Lightweight stage samples: no forced collection or expanded-tree traversal. --- Allocation/live counters reflect the most recent RTS collection. -compilerSample :: String -> IO () -compilerSample label = do - wall <- getMonotonicTimeNSec - stats <- getRTSStats - BL.hPutStr stderr (JSON.encode (JSON.object - [ "compiler_stage" JSON..= label - , "wall_ns" JSON..= wall - , "allocated_bytes" JSON..= allocated_bytes stats - , "live_bytes" JSON..= gcdetails_live_bytes (gc stats) - , "gc_elapsed_ns" JSON..= gc_elapsed_ns stats - ])) - BL.hPutStr stderr "\n" - -- Each sample forces a major collection. Deltas include sampling overhead; -- yielded samples retain the scheduler's pending work. Rendering follows timing. profileSample :: String -> Maybe YieldStats -> IO () @@ -1219,12 +1095,6 @@ runExportDag opts = do -- Helpers -- --------------------------------------------------------------------------- -resolveStorePath :: Maybe FilePath -> IO StorePath -resolveStorePath (Just path) = return (StorePath path) -resolveStorePath Nothing = do - home <- getHomeDirectory - return (StorePath (home </> ".tricu" </> "store")) - treeTermRef :: ObjectHash -> ObjectRef treeTermRef = ObjectRef (unDomain treeTermDomain) diff --git a/src/PortableCompiler.hs b/src/PortableCompiler.hs @@ -0,0 +1,159 @@ +-- Host supervision of an authenticated portable compiler, not source semantics. +-- Errors are IO failures so CLI, REPL and tests choose their own presentation. +module PortableCompiler + ( CompilerOptions(..), validateCompilerOptions, checkPortableCompiler + , buildPortableModule, buildPortableResult, portableExports + ) where + +import ContentStore +import ContentStore.Bundle (unpackIndexedBundleToStore, indexedTreeDomain) +import ContentStore.Executable +import Module.Manifest +import Runtime (T(..), EvaluatedForm(Decode), apply, ofString, ofNumber, toString, formatT) +import IODriver (IOPermissions(..), runIO) +import Paths_tricu (getDataFileName) +import Crypto.Hash (Digest, SHA256, hash) +import Control.Exception (IOException, catch) +import Control.Monad (when, unless) +import System.Directory (canonicalizePath, createDirectoryIfMissing) +import System.FilePath (takeExtension, takeDirectory, takeBaseName) +import System.Timeout (timeout) +import System.IO (stderr) +import GHC.Clock (getMonotonicTimeNSec) +import GHC.Stats +import qualified Data.Text as T +import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy as BL +import qualified Data.Aeson as JSON + +-- Explicit artifact selection; absence selects the packaged stable compiler. +-- Tool stores are never populated by implicit source loading. +data CompilerOptions = CompilerOptions + { compilerManifest :: Maybe String + , compilerToolStore :: Maybe FilePath + , compilerSeconds :: Int + , compilerProfile :: Bool + } deriving (Show) + +-- Startup and actual compilation use the same artifact selection, validation +-- and load deadline. Neither path may fall back from an explicit toolchain. +checkPortableCompiler :: CompilerOptions -> StorePath -> IO () +checkPortableCompiler options store = do + validateCompilerOptions options + compilerDeadline options $ do + _ <- loadCompiler (const $ pure ()) options store + pure () + +compilerDeadline :: CompilerOptions -> IO a -> IO a +compilerDeadline options action = do + result <- timeout (compilerSeconds options * 1000000) action + maybe (fail "Portable compiler load/build deadline exceeded") pure result + +loadCompiler :: (String -> IO ()) -> CompilerOptions -> StorePath -> IO (String, Runtime.T) +loadCompiler sample options store = do + let tools = maybe store StorePath (compilerToolStore options) + pin <- maybe (packagedCompiler tools) (pure . T.pack) (compilerManifest options) + (identity, worker) <- loadVerifiedExportWith sample tools pin "sourceBuildGraph" + pure (case compilerManifest options of + Nothing -> packagedCompilerSha256 + Just _ -> T.unpack identity, worker) + +-- Cabal records an installation-relative data directory. Nix-built binaries +-- retain their data closure when invoked outside the checkout. An explicit +-- tricu_datadir override is useful for relocatable distributions and diagnostics. +packagedCompilerSha256 :: String +packagedCompilerSha256 = "5d36d069562ca9b208cfd0aa8df9f0d3b5c39f8b13e070fb892e21b38e32ffb4" + +packagedCompiler :: StorePath -> IO T.Text +packagedCompiler store = do + path <- getDataFileName "bootstrap/build-stable.arboricx" + bytes <- BS.readFile path `catch` (\(err :: IOException) -> + fail $ "Packaged stable compiler unavailable at " ++ path ++ ": " ++ show err) + unless (show (hash bytes :: Digest SHA256) == packagedCompilerSha256) $ + fail "Packaged stable compiler SHA256 mismatch; reinstall or select --toolchain explicitly" + roots <- unpackIndexedBundleToStore store bytes + putManifest store (ModuleManifest [] + [ModuleExport name (ObjectRef (unDomain indexedTreeDomain) root) + "arboricx.abi.tree.v1" | (name, root) <- roots]) + +portableExports :: CompilerOptions -> Maybe FilePath -> FilePath -> IO [(String, Runtime.T)] +portableExports options selected path = do + (store, pin) <- buildPortableModule options selected path + loadVerifiedExports store pin + +validateCompilerOptions :: CompilerOptions -> IO () +validateCompilerOptions options = do + when (compilerManifest options == Nothing && compilerToolStore options /= Nothing) $ + fail "--tool-store requires --toolchain" + unless (compilerSeconds options >= 1 && compilerSeconds options <= 86400) $ + fail "--compile-timeout must be between 1 and 86400 seconds" + +-- Native flat-workspace profile: the selected file supplies the directory and +-- target name; local import/re-export edges select NAME.tri within that explicit base. +-- All header staging, scope, compilation, publication and cache policy is tricu. +buildPortableModule :: CompilerOptions -> Maybe FilePath -> FilePath -> IO (StorePath, T.Text) +buildPortableModule options appStore input = do + (store,manifest,_) <- buildPortableResult options appStore input + pure (store,manifest) + +-- Keep the compiler-returned receipt available to reproduction tooling instead +-- of recovering execution evidence from a mutable cache locator afterward. +buildPortableResult :: CompilerOptions -> Maybe FilePath -> FilePath -> IO (StorePath, T.Text, T.Text) +buildPortableResult options appStore input = do + validateCompilerOptions options + when (compilerProfile options) $ do + enabled <- getRTSStatsEnabled + unless enabled $ fail "--compile-profile requires +RTS -T" + let sample label = when (compilerProfile options) (compilerSample label) + sample "begin" + source <- canonicalizePath input + unless (takeExtension source == ".tri") $ fail "Portable compilation requires a .tri module file" + store0 <- resolveStorePath appStore + createDirectoryIfMissing True (unStorePath store0) + root <- canonicalizePath (unStorePath store0) + let store = StorePath root + directory = takeDirectory source + name = takeBaseName source + built <- compilerDeadline options $ do + (compilerIdentity, worker) <- loadCompiler sample options store + let action = foldl apply worker (map ofString + [directory, root, compilerIdentity, name]) + permissions = IOPermissions [directory, root] [root] False False + -- sourceBuildGraph returns a bare v1 action; this is transport framing, + -- not source compilation or a source-loaded IO adapter. + let envelope = Fork (ofString "tricuIO") (Fork (ofNumber 1) action) + output <- runIO permissions envelope >>= either (fail . ("Compiler IO error: " ++)) pure + sample "build-returned" + case output of + Fork (Stem Leaf) (Fork (Fork manifest receipt) _) -> do + manifestHash <- either (fail . ("Invalid compiler manifest output: " ++)) (pure . T.pack) + (toString manifest) + receiptHash <- either (fail . ("Invalid compiler receipt output: " ++)) (pure . T.pack) + (toString receipt) + _ <- loadVerifiedManifest store manifestHash + either (fail . ("Invalid compiler receipt hash: " ++)) pure (textToHashBytes receiptHash) + receiptBytes <- getObject store receiptHash >>= maybe (fail "Compiler receipt missing") pure + unless (hashObject (Domain "arboricx.build-receipt.v1") receiptBytes == receiptHash) $ + fail "Compiler receipt digest mismatch" + pure (manifestHash,receiptHash) + Fork Leaf (Fork problem _) -> fail $ "Portable compilation failed: " ++ formatT Decode problem + _ -> fail "Invalid portable compiler Result" + let (manifest,receipt) = built + sample "publication-verified" + pure (store, manifest,receipt) + +-- Lightweight stage samples: no forced collection or expanded-tree traversal. +-- Allocation/live counters reflect the most recent RTS collection. +compilerSample :: String -> IO () +compilerSample label = do + wall <- getMonotonicTimeNSec + stats <- getRTSStats + BL.hPutStr stderr (JSON.encode (JSON.object + [ "compiler_stage" JSON..= label + , "wall_ns" JSON..= wall + , "allocated_bytes" JSON..= allocated_bytes stats + , "live_bytes" JSON..= gcdetails_live_bytes (gc stats) + , "gc_elapsed_ns" JSON..= gc_elapsed_ns stats + ])) + BL.hPutStr stderr "\n" + diff --git a/test/ExecutableLoadingTests.hs b/test/ExecutableLoadingTests.hs @@ -42,6 +42,12 @@ executableLoadingTests = testGroup "shared production executable loader" $ pin <- putManifest store (ModuleManifest [] [export "z" ref, export "a" ref]) loadVerifiedExports store pin >>= (@?= [("z", Stem Leaf), ("a", Stem Leaf)]) rejected "Module export not found" (loadVerifiedExport store pin "absent") + , testCase "selection preserves caller order and does not read unselected objects" $ fresh $ \store -> do + ref <- publish store Leaf + pin <- putManifest store (ModuleManifest [] + [export "x" ref, export "missing" (ref {objectRefHash = missing}), export "y" ref]) + loadVerifiedExportSelection store pin (Just ["y", "x"]) >>= (@?= [("y", Leaf), ("x", Leaf)]) + rejected "not found" (loadVerifiedExports store pin) , testCase "tampering fails before decoding; correct digest is not enough" $ fresh $ \store -> do ref <- publish store Leaf pin <- putManifest store (ModuleManifest [] [export "main" ref]) diff --git a/test/PortableCompilerTests.hs b/test/PortableCompilerTests.hs @@ -0,0 +1,119 @@ +-- Artifact-only supervisor controls. These workers are constructed ABI trees, +-- not compilations of either the test source or the compiler implementation. +module PortableCompilerTests (portableCompilerTests) where + +import PortableCompiler +import ContentStore +import Module.Manifest +import Runtime +import Control.Exception (IOException, try) +import Control.Monad (forM_) +import Data.List (isInfixOf) +import qualified Data.ByteString as BS +import qualified Data.Text as Text +import System.Directory (canonicalizePath) +import System.FilePath ((</>)) +import System.IO.Temp (withSystemTempDirectory) +import qualified System.Timeout as Timeout +import Test.Tasty +import Test.Tasty.HUnit + +portableCompilerTests :: TestTree +portableCompilerTests = testGroup "portable compiler supervision" + [ testCase "valid publication and explicit selection work without source interpretation" $ fresh $ \store source -> do + manifest <- putManifest store (ModuleManifest [] []) + receipt <- putObject store receiptDomain "independent receipt bytes" + options <- worker store (pureAction (success manifest receipt)) + checkPortableCompiler options store + canonical <- canonicalizePath (unStorePath store) + buildPortableResult options (Just (unStorePath store)) source >>= + (@?= (StorePath canonical, manifest, receipt)) + portableExports options (Just (unStorePath store)) source >>= (@?= []) + , testCase "invalid options fail at API entry" $ fresh $ \store source -> do + forM_ [0, -1, 86401, maxBound] $ \seconds -> + rejected "--compile-timeout" (buildPortableModule (defaults {compilerSeconds = seconds}) Nothing source) + let invalid = defaults {compilerToolStore = Just (unStorePath store)} + rejected "--tool-store requires --toolchain" (checkPortableCompiler invalid store) + , testCase "explicit missing compiler never falls back to packaged data" $ fresh $ \store source -> do + let options = defaults {compilerManifest = Just (Text.unpack missing)} + rejected "Module manifest not found" (checkPortableCompiler options store) + rejected "Module manifest not found" (buildPortableModule options (Just (unStorePath store)) source) + , testCase "startup verifies the worker export ABI" $ fresh $ \store _ -> do + root <- putTreeTerm store Leaf + pin <- putManifest store (ModuleManifest [] [ModuleExport "sourceBuildGraph" + (ObjectRef (unDomain treeTermDomain) root) "unsupported"]) + rejected "Unsupported module export ABI" (checkPortableCompiler + (defaults {compilerManifest = Just (Text.unpack pin)}) store) + , testCase "compiler Results and malformed actions are checked" $ fresh $ \store source -> do + forM_ [(pureAction Leaf, "Invalid portable compiler Result"), + (pureAction (Fork Leaf (Fork (ofString "test failure") Leaf)), "Portable compilation failed"), + (Leaf, "invalid action")] $ \(action, diagnostic) -> do + options <- worker store action + rejected diagnostic (buildPortableModule options (Just (unStorePath store)) source) + , testCase "returned manifest and receipt are authenticated" $ fresh $ \store source -> do + manifest <- putManifest store (ModuleManifest [] []) + receipt <- putObject store receiptDomain "receipt" + let check m r diagnostic = do + options <- worker store (pureAction (success m r)) + rejected diagnostic (buildPortableModule options (Just (unStorePath store)) source) + check missing receipt "Module manifest not found" + check manifest missing "Compiler receipt missing" + check manifest "../escape" "Invalid compiler receipt hash" + BS.writeFile (objectPath store receipt) "tampered receipt" + check manifest receipt "Compiler receipt digest mismatch" + BS.writeFile (objectPath store manifest) "tampered manifest" + check manifest receipt "Module manifest digest mismatch" + , testCase "compiler cannot overwrite source files" $ fresh $ \store source -> do + before <- BS.readFile source + options <- worker store (Fork (ofNumber 21) + (Fork (ofString source) (ofString "overwritten"))) + rejected "permission denied" (buildPortableModule options (Just (unStorePath store)) source) + BS.readFile source >>= (@?= before) + , testCase "compiler execution remains inside the deadline" $ fresh $ \store source -> do + -- Sleep 60 seconds: scheduler effects are interrupted, not just loading. + options <- worker store (Fork (ofNumber 63) (ofNumber 60000)) + -- Startup verifies an inert worker; it must not execute this sleep. + ready <- Timeout.timeout 5000000 (checkPortableCompiler options store) + ready @?= Just () + completed <- Timeout.timeout 5000000 $ rejected "deadline exceeded" + (buildPortableModule (options {compilerSeconds = 1}) (Just (unStorePath store)) source) + completed @?= Just () + ] + +fresh :: (StorePath -> FilePath -> IO a) -> IO a +fresh action = withSystemTempDirectory "portable-supervisor" $ \dir -> do + let source = dir </> "module.tri" + -- A supervisor must not interpret this input; only the selected worker may. + BS.writeFile source "deliberately not tricu source" + action (StorePath (dir </> "store")) source + +defaults :: CompilerOptions +defaults = CompilerOptions Nothing Nothing 10 False + +missing :: Text.Text +missing = Text.replicate 64 "0" + +receiptDomain :: Domain +receiptDomain = Domain "arboricx.build-receipt.v1" + +pureAction :: T -> T +pureAction = Fork Leaf + +success :: Text.Text -> Text.Text -> T +success manifest receipt = Fork (Stem Leaf) + (Fork (Fork (ofString (Text.unpack manifest)) (ofString (Text.unpack receipt))) Leaf) + +worker :: StorePath -> T -> IO CompilerOptions +worker store action = do + -- Four ignored build arguments, followed by the bare IO action. + root <- putTreeTerm store (iterate (Fork Leaf) action !! 4) + pin <- putManifest store (ModuleManifest [] [ModuleExport "sourceBuildGraph" + (ObjectRef (unDomain treeTermDomain) root) "arboricx.abi.tree.v1"]) + pure defaults {compilerManifest = Just (Text.unpack pin)} + +rejected :: String -> IO a -> Assertion +rejected needle action = do + result <- try (action >> pure ()) :: IO (Either IOException ()) + case result of + Left problem -> assertBool (show problem) (needle `isInfixOf` show problem) + Right () -> assertFailure ("accepted invalid input; expected " ++ needle) diff --git a/test/Spec.hs b/test/Spec.hs @@ -1,5 +1,6 @@ module Main where +import PortableCompilerTests (portableCompilerTests) import ExecutableLoadingTests (executableLoadingTests) import ScriptedEffects (runScriptedEffects) import IODriverTests (ioDriverTests) @@ -56,7 +57,8 @@ main = do tests :: TestTree tests = testGroup "Tricu Tests" - [ executableLoadingTests + [ portableCompilerTests + , executableLoadingTests , runtimePresentationTests , interactionInspectionTests , packageTests diff --git a/tricu.cabal b/tricu.cabal @@ -86,6 +86,7 @@ executable tricu Module.Manifest Paths_tricu REPL + PortableCompiler Runtime Runtime.Tree Runtime.Value @@ -178,6 +179,7 @@ test-suite tricu-tests Module.Provenance PackageTests ExecutableLoadingTests + PortableCompilerTests InteractionInspectionTests ContentStore ContentStore.Alias @@ -193,6 +195,7 @@ test-suite tricu-tests Module.Manifest Paths_tricu REPL + PortableCompiler Runtime Runtime.Tree Runtime.Value