Add multi-root bundle support across the toolchain: - `compile`: Accept multiple definition names via `-x NAME` (repeatable or comma-separated). Exports all requested definitions as named roots in a single bundle. Defaults to "main" when no names are given. - `export`: Accept comma-separated hashes in the positional argument and multiple `-n`/`names` flags. Exports all resolved roots in one bundle. - Server: Add `GET /bundle/roots?n=...&h=...` endpoint that resolves multiple stored-term names and/or raw Merkle hashes, returning a single bundle containing all of them as roots. - Wire: Export `defaultExportNames` helper for generating default export names when none are supplied. - Drop `cereal` dependency from `tricu.cabal` (no longer used).
233 lines
8.0 KiB
Haskell
233 lines
8.0 KiB
Haskell
module Server
|
|
( runServer
|
|
) where
|
|
|
|
import ContentStore (initContentStore, nameToTerm, hashToTerm, listStoredTerms,
|
|
parseNameList, StoredTerm(..), termHash)
|
|
import Database.SQLite.Simple (close)
|
|
import Wire (exportNamedBundle)
|
|
|
|
import Control.Monad (when)
|
|
import Data.Maybe (catMaybes)
|
|
import Control.Monad (void)
|
|
|
|
import Network.HTTP.Types (Header, Status, status200, status400, status404, status405, hContentType)
|
|
import Network.Wai
|
|
import Network.Wai.Handler.Warp (defaultSettings, runSettings, setHost, setPort)
|
|
|
|
import Data.String (fromString)
|
|
import Data.Text (Text)
|
|
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
|
|
import Data.Char (isHexDigit, toLower)
|
|
import Data.ByteString.Char8 (unpack)
|
|
import Data.ByteString.Lazy (fromStrict)
|
|
import qualified Data.Text as T
|
|
|
|
-- | Start an HTTP server that serves Arborix bundles from the
|
|
-- local content store.
|
|
--
|
|
-- This is a read-only export surface. Clients fetch bundle bytes
|
|
-- and independently inspect / verify / run them. The server does
|
|
-- not execute bundles.
|
|
--
|
|
-- Bind host defaults to @127.0.0.1@.
|
|
--
|
|
-- Endpoints
|
|
-- ---------
|
|
-- GET /health - 200 "ok"
|
|
-- GET /bundle/name/:name - export single term by name
|
|
-- GET /bundle/hash/:hash - export single term by hash
|
|
-- GET /bundle/roots?n=...&h=... - export multiple roots (n=name, h=hash)
|
|
-- GET /terms - plain-text listing (debug)
|
|
--
|
|
runServer :: String -> Int -> IO ()
|
|
runServer hostStr port =
|
|
runSettings settings app
|
|
where
|
|
settings = setPort port $ setHost (fromString hostStr) defaultSettings
|
|
|
|
-- | WAI application backed by the content store.
|
|
-- Uses the same database path as @eval@ mode (env var
|
|
-- @TRICU_DB_PATH@ or the default location).
|
|
app :: Application
|
|
app request respond = case (requestMethod request, pathInfo request) of
|
|
("GET", ["health"]) ->
|
|
respond $ healthResponse
|
|
|
|
("GET", ["bundle", "roots"]) ->
|
|
rootsHandler request respond
|
|
|
|
("GET", ["bundle", "name", nameText]) -> do
|
|
body <- nameHandler nameText
|
|
respond body
|
|
|
|
("GET", ["bundle", "hash", hashText]) -> do
|
|
body <- hashHandler hashText
|
|
respond body
|
|
|
|
("GET", ["terms"]) -> do
|
|
body <- termsResponse
|
|
respond body
|
|
|
|
("POST", _) ->
|
|
respond $ responseLBS status405 [] "Method not allowed"
|
|
|
|
("PUT", _) ->
|
|
respond $ responseLBS status405 [] "Method not allowed"
|
|
|
|
("DELETE", _) ->
|
|
respond $ responseLBS status405 [] "Method not allowed"
|
|
|
|
_ ->
|
|
respond $ responseLBS status404 [] "not found"
|
|
|
|
healthResponse :: Response
|
|
healthResponse = responseLBS status200 [] "ok"
|
|
|
|
-- | GET /bundle/roots?n=root&n=helper&h=abc123...
|
|
-- Resolve multiple named roots (by stored term name or raw hash)
|
|
-- and return a single bundle containing all of them.
|
|
--
|
|
-- Query parameters:
|
|
-- - @n=<name>@ — one or more stored term names (resolved via nameToTerm)
|
|
-- - @h=<hash>@ — one or more full Merkle hashes (validated as 16-64 hex chars)
|
|
--
|
|
-- The bundle manifest receives all resolved (name, hash) pairs as roots
|
|
-- and exports. The node section is the union of all reachable nodes.
|
|
rootsHandler :: Request -> (Response -> IO a) -> IO a
|
|
rootsHandler request respond = do
|
|
conn <- initContentStore
|
|
let qs = queryString request
|
|
nParams = catMaybes [v | (k, v) <- qs, map toLower (unpack k) == "n"]
|
|
hParams = catMaybes [v | (k, v) <- qs, map toLower (unpack k) == "h"]
|
|
-- Resolve 'n' params to (name, hash) pairs
|
|
nResults <- mapM (\nVal -> do
|
|
stored <- nameToTerm conn (decodeUtf8 nVal)
|
|
case stored of
|
|
Nothing -> return Nothing
|
|
Just t -> return $ Just (decodeUtf8 nVal, termHash t)) nParams
|
|
let namedHashesFromN = catMaybes nResults
|
|
-- Validate 'h' params and build (name, hash) pairs
|
|
namedHashesFromH <- mapM (\hVal -> do
|
|
let raw = T.pack (dropWhile (=='#') (T.unpack (decodeUtf8 hVal)))
|
|
if T.all isHexDigit raw && T.length raw >= 16
|
|
then do
|
|
stored <- hashToTerm conn raw
|
|
let names = maybe "root" firstOrRoot (termNames <$> stored)
|
|
return $ Just (names, raw)
|
|
else return Nothing)
|
|
hParams
|
|
let allNamedHashes = namedHashesFromN ++ catMaybes namedHashesFromH
|
|
-- Require at least one root
|
|
when (null allNamedHashes) $ do
|
|
let resp = responseLBS status400 [] "400 Bad Request: at least one n= or h= parameter required"
|
|
close conn
|
|
void $ respond resp
|
|
-- Build and return the bundle
|
|
bundleData <- exportNamedBundle conn allNamedHashes
|
|
let firstHash = snd (head allNamedHashes)
|
|
cd = T.pack "attachment; filename=roots.bundle"
|
|
close conn
|
|
respond $ responseLBS status200
|
|
(bundleHeaders firstHash cd)
|
|
(fromStrict bundleData)
|
|
|
|
-- | GET /bundle/name/:name
|
|
-- Resolve a stored term name, export it as an Arborix bundle,
|
|
-- and return the raw bundle bytes.
|
|
--
|
|
-- Sets @Content-Type@ and @X-Arborix-Root-Hash@ headers.
|
|
-- Returns 404 when the name does not resolve to any stored term.
|
|
nameHandler :: Text -> IO Response
|
|
nameHandler nameText = do
|
|
conn <- initContentStore
|
|
stored <- nameToTerm conn nameText
|
|
case stored of
|
|
Nothing -> do
|
|
close conn
|
|
return $ textResponse status404 ("not found: " <> nameText)
|
|
Just term' -> do
|
|
let th = termHash term'
|
|
namedHashes = [(firstOrRoot (termNames term'), th)]
|
|
bundleData <- exportNamedBundle conn namedHashes
|
|
let cd = T.pack $ "attachment; filename=" ++ safeFileName (T.unpack nameText) ++ ".bundle"
|
|
close conn
|
|
return $ responseLBS status200 (bundleHeaders th cd) (fromStrict bundleData)
|
|
|
|
-- | GET /bundle/hash/:hash
|
|
-- Resolve a full Merkle hash and export the root as an Arborix
|
|
-- bundle.
|
|
--
|
|
-- - Malformed hash (non-hex or < 16 chars): 400
|
|
-- - Well-formed but absent: 404
|
|
-- - Present: 200 with bundle bytes
|
|
hashHandler :: Text -> IO Response
|
|
hashHandler hashText =
|
|
let raw = T.pack (dropWhile (== '#') (T.unpack hashText))
|
|
in if not (T.all isHexDigit raw) || T.length raw < 16
|
|
then return $ responseLBS status400 [] "400 Bad Request: invalid hash"
|
|
else do
|
|
conn <- initContentStore
|
|
stored <- hashToTerm conn raw
|
|
case stored of
|
|
Nothing -> do
|
|
close conn
|
|
return $ textResponse status404 ("not found: " <> hashText)
|
|
Just term' -> do
|
|
let th = termHash term'
|
|
namedHashes' = [(firstOrRoot (termNames term'), th)]
|
|
bundleData <- exportNamedBundle conn namedHashes'
|
|
close conn
|
|
return $ responseLBS status200
|
|
(bundleHeaders th "attachment; filename=hash.bundle")
|
|
(fromStrict bundleData)
|
|
|
|
-- | GET /terms
|
|
-- Plain-text listing of all stored terms (debugging only).
|
|
termsResponse :: IO Response
|
|
termsResponse = do
|
|
conn <- initContentStore
|
|
terms <- listStoredTerms conn
|
|
close conn
|
|
let lines' = [ names <> " " <> hash <> " " <> T.pack (show created)
|
|
| term <- terms
|
|
, let names = termNames term
|
|
, let hash = termHash term
|
|
, let created = termCreatedAt term ]
|
|
return $ responseLBS status200
|
|
[ (hContentType, encodeUtf8 "text/plain; charset=utf-8")
|
|
]
|
|
(fromStrict $ encodeUtf8 $ T.unlines lines')
|
|
|
|
textResponse :: Status -> Text -> Response
|
|
textResponse status body =
|
|
responseLBS status
|
|
[ (hContentType, encodeUtf8 "text/plain; charset=utf-8") ]
|
|
(fromStrict $ encodeUtf8 body)
|
|
|
|
bundleHeaders :: Text -> Text -> [Header]
|
|
bundleHeaders root cd =
|
|
[ (hContentType, encodeUtf8 "application/vnd.arborix.bundle")
|
|
, ("X-Arborix-Root-Hash", encodeUtf8 root)
|
|
, ("Content-Disposition", encodeUtf8 cd)
|
|
]
|
|
|
|
-- | Pick the first stored name, falling back to "root" when names are empty.
|
|
firstOrRoot :: Text -> Text
|
|
firstOrRoot names =
|
|
case parseNameList names of
|
|
[] -> "root"
|
|
(x:_) -> x
|
|
|
|
-- | Sanitise a string to a safe filename prefix.
|
|
safeFileName :: String -> String
|
|
safeFileName = map go
|
|
where
|
|
go c
|
|
| c >= 'a' && c <= 'z' = c
|
|
| c >= 'A' && c <= 'Z' = c
|
|
| c >= '0' && c <= '9' = c
|
|
| c == '-' = c
|
|
| c == '_' = c
|
|
| otherwise = '_'
|