tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

Provenance.hs (23372B)


      1 -- Retain inputs around native receipts; never invent a second build receipt.
      2 module Module.Provenance
      3   ( capture, verifyPack, prepareRebuild, finishRebuild, Recipe(..) ) where
      4 
      5 import Workspace (workspaceSources)
      6 import ContentStore.Object
      7 import ContentStore.Alias (ObjectRef(..))
      8 import ContentStore.Filesystem (objectPath, putObject)
      9 import Module.Manifest
     10 import qualified Module.Inspection as I
     11 import qualified Package as P
     12 import qualified Project as J
     13 import Control.Exception (bracket)
     14 import Control.Monad (unless, when, forM_, foldM)
     15 import qualified Data.ByteString as BS
     16 import Data.Text (Text)
     17 import qualified Data.Text as T
     18 import Data.Text.Encoding (encodeUtf8, decodeUtf8')
     19 import qualified Data.Map.Strict as M
     20 import qualified Data.Set as S
     21 import Data.IORef
     22 import System.Directory (createDirectory, createDirectoryIfMissing)
     23 import System.FilePath ((</>), takeDirectory, takeBaseName, takeExtension)
     24 import System.IO (openBinaryFile, IOMode(ReadMode), hClose)
     25 import System.Posix.Files (getSymbolicLinkStatus, isRegularFile, isDirectory)
     26 
     27 receiptDomain, sourceDomain, optionsDomain :: Domain
     28 receiptDomain = Domain "arboricx.build-receipt.v1"
     29 sourceDomain = Domain "arboricx.source.v1"
     30 optionsDomain = Domain "arboricx.build-options.v1"
     31 profile :: BS.ByteString
     32 profile = "source-module.stable.v1"
     33 
     34 data Receipt = Receipt
     35   { rSource :: Text, rCompiler :: Text, rOptions :: Text, rOutput :: Text
     36   , rLocals :: [(Text,Text,Text)], rPins :: [(Text,Text,Text,Text)]
     37   } deriving (Eq, Show)
     38 data Recipe = Recipe
     39   { recipeEntry :: Text, recipeReceipt :: Text, recipeCompiler :: Text
     40   , recipeIOVersion :: Int
     41   , recipeUnits :: [(Text,Text)], recipeProject :: Maybe (Text,Text)
     42   } deriving (Eq, Show)
     43 data Closure = Closure
     44   { cRecipe :: Recipe, cObjects :: M.Map (Text,Text) BS.ByteString
     45   , cReceipts :: M.Map Text (Text,Receipt), cExternal :: S.Set Text
     46   }
     47 
     48 bounded :: Int -> FilePath -> IO BS.ByteString
     49 bounded limit file = do
     50   status <- getSymbolicLinkStatus file
     51   unless (isRegularFile status) $ fail "build inputs require regular files (no symlinks)"
     52   bracket (openBinaryFile file ReadMode) hClose $ \h -> do
     53     bytes <- BS.hGet h (limit + 1)
     54     when (BS.length bytes > limit) $ fail "build input exceeds byte limit"
     55     pure bytes
     56 
     57 textRows :: Text -> BS.ByteString -> Either String [[Text]]
     58 textRows header bytes = do
     59   txt <- either (Left . show) Right (decodeUtf8' bytes)
     60   case T.lines txt of
     61     h:rest | h == header && encodeUtf8 (T.unlines (h:rest)) == bytes -> Right (map (T.splitOn "\t") rest)
     62     _ -> Left "unsupported/noncanonical build metadata"
     63 
     64 parseReceipt :: BS.ByteString -> Either String Receipt
     65 parseReceipt bytes = do
     66   rows <- textRows "arboricx.build-receipt.v1" bytes
     67   case rows of
     68     ["source",s]:["compiler",c]:["options",o]:["output",out]:rest -> do
     69       mapM_ validateObjectHash [s,c,o,out]
     70       let (locals,pins) = span (\row -> take 1 row == ["local"]) rest
     71       ls <- mapM (\row -> case row of
     72         ["local",n,m,r] | J.validBinding n -> mapM_ validateObjectHash [m,r] >> pure (n,m,r)
     73         _ -> Left "invalid receipt local row") locals
     74       ps <- mapM (\row -> case row of
     75         ["pin",ref,m,n,t] -> mapM_ validateObjectHash [m,t] >> pure (ref,m,n,t)
     76         _ -> Left "invalid receipt pin row") pins
     77       unless (S.size (S.fromList [n | (n,_,_) <- ls]) == length ls) $ Left "duplicate receipt local"
     78       unless (S.size (S.fromList [(ref,n) | (ref,_,n,_) <- ps]) == length ps) $ Left "duplicate receipt pin"
     79       unless (o == hashObject optionsDomain profile) $ Left "unsupported build options; expected stable flat-workspace profile"
     80       pure (Receipt s c o out ls ps)
     81     _ -> Left "invalid native build receipt"
     82 
     83 -- The historical profile remains byte-for-byte v1. The v2 profile explicitly
     84 -- binds OS-authority execution; neither schema permits guessing or fallback.
     85 recipeHeader :: Int -> Text
     86 recipeHeader 2 = "arboricx.build-inputs.v2"
     87 recipeHeader _ = "arboricx.build-inputs.v1"
     88 recipeDomain :: Int -> Domain
     89 recipeDomain = Domain . recipeHeader
     90 
     91 recipeVersion :: BS.ByteString -> Either String Int
     92 recipeVersion bytes = case BS.takeWhile (/= 10) bytes of
     93   "arboricx.build-inputs.v1" -> Right 1
     94   "arboricx.build-inputs.v2" -> Right 2
     95   _ -> Left "unsupported build inputs schema"
     96 
     97 encodeRecipe :: Recipe -> BS.ByteString
     98 encodeRecipe r = encodeUtf8 $ T.unlines $
     99   [recipeHeader (recipeIOVersion r), "entry\t" <> recipeEntry r, "receipt\t" <> recipeReceipt r,
    100    "compiler\t" <> recipeCompiler r] ++
    101   ["compiler-io\t2" | recipeIOVersion r == 2] ++
    102   ["unit\t" <> n <> "\t" <> p | (n,p) <- recipeUnits r] ++
    103   maybe [] (\(p,l) -> ["project\t" <> p <> "\t" <> l]) (recipeProject r)
    104 
    105 decodeRecipe :: BS.ByteString -> Either String Recipe
    106 decodeRecipe bytes = do
    107   ioVersion <- recipeVersion bytes
    108   rows <- textRows (recipeHeader ioVersion) bytes
    109   case rows of
    110     ["entry",entry]:["receipt",receipt]:["compiler",compiler]:tailRows | J.validBinding entry -> do
    111       rest <- if ioVersion == 1 then Right tailRows else case tailRows of
    112         ["compiler-io","2"]:remaining -> Right remaining
    113         _ -> Left "v2 build inputs require compiler-io 2"
    114       let (units,context) = span (\row -> take 1 row == ["unit"]) rest
    115       us <- mapM (\row -> case row of
    116         ["unit",n,p] | J.validBinding n -> validateObjectHash p >> pure (n,p)
    117         _ -> Left "invalid build source unit") units
    118       unless (not (null us) && length us <= 128 && M.toAscList (M.fromList us) == us &&
    119               S.size (S.fromList (map (T.toCaseFold . fst) us)) == length us) $ Left "invalid/duplicate/unsorted source units"
    120       mapM_ validateObjectHash [receipt,compiler]
    121       ctx <- case context of
    122         [] -> Right Nothing
    123         [["project",p,l]] -> mapM_ validateObjectHash [p,l] >> pure (Just (p,l))
    124         _ -> Left "invalid project context"
    125       unless (lookup entry us == Just receipt) $ Left "entry receipt not in source units"
    126       let result = Recipe entry receipt compiler ioVersion us ctx
    127       unless (encodeRecipe result == bytes) $ Left "noncanonical build inputs"
    128       pure result
    129     _ -> Left "invalid build inputs"
    130 
    131 -- Byte authentication is separate from the producer's claimed input/output link.
    132 -- Collect only the referenced closure; never copy caches or ambient aliases.
    133 collect :: Recipe -> (Domain -> Text -> IO BS.ByteString) -> (Text -> Text -> IO BS.ByteString) -> IO Closure
    134 collect initial load source = do
    135   objects <- newIORef M.empty
    136   total <- newIORef (0 :: Int)
    137   let retain d pin bytes = do
    138         either fail pure (verifyObjectBytes d pin bytes)
    139         old <- readIORef objects
    140         unless (M.member (unDomain d,pin) old) $ do
    141           size <- atomicModifyIORef' total (\n -> let next = n + BS.length bytes in (next,next))
    142           when (size > 256 * 1024 * 1024) $ fail "build input closure exceeds 256 MiB"
    143           modifyIORef' objects (M.insert (unDomain d,pin) bytes)
    144         pure bytes
    145       get d p = do
    146         known <- M.lookup (unDomain d,p) <$> readIORef objects
    147         maybe (load d p >>= retain d p) pure known
    148       moduleObjects p = do
    149         bytes <- get manifestDomain p
    150         m <- either fail pure (decodeManifest bytes)
    151         unless (encodeManifest m == bytes) $ fail "noncanonical native manifest in build inputs"
    152         unless (null (moduleManifestReferences m) && not (null (moduleManifestExports m))) $ fail "unsupported module profile in build inputs"
    153         forM_ (moduleManifestExports m) $ \ex -> do
    154           let ref = moduleExportObject ex
    155           unless (objectRefKind ref == "arboricx.indexed-tree.v1" && moduleExportAbi ex == "arboricx.abi.tree.v1") $
    156             fail "unsupported executable in build inputs"
    157           _ <- get (Domain (objectRefKind ref)) (objectRefHash ref)
    158           pure ()
    159         pure m
    160   compiler <- moduleObjects (recipeCompiler initial)
    161   worker <- case [objectRefHash (moduleExportObject ex) | ex <- moduleManifestExports compiler, moduleExportName ex == "sourceBuildGraph"] of
    162     [p] -> pure p
    163     _ -> fail "compiler must export sourceBuildGraph"
    164   let walk stack done name pin = do
    165         when (name `elem` stack) $ fail "cyclic source receipt graph"
    166         case M.lookup name done of
    167           Just (previous,_) | previous == pin -> pure done
    168                             | otherwise -> fail "one workspace name refers to multiple receipts"
    169           Nothing -> do
    170             unless (J.validBinding name && M.size done + length stack < 128) $ fail "invalid/oversized source receipt graph"
    171             receipt <- get receiptDomain pin >>= either fail pure . parseReceipt
    172             unless (rCompiler receipt == worker) $ fail "receipt compiler identity differs from selected worker; this profile requires explicit --toolchain builds"
    173             _ <- get optionsDomain (rOptions receipt)
    174             _ <- source name (rSource receipt) >>= retain sourceDomain (rSource receipt)
    175             _ <- moduleObjects (rOutput receipt)
    176             children <- foldM (\acc (n,m,r) -> do
    177               next <- walk (name:stack) acc n r
    178               unless (maybe False ((== m) . rOutput . snd) (M.lookup n next)) $ fail "local receipt output mismatch"
    179               pure next) done (rLocals receipt)
    180             let groups = M.fromListWith (++) [(ref,[(m,n,t)]) | (ref,m,n,t) <- rPins receipt]
    181                 escape = T.replace "\r" "%0D" . T.replace "\n" "%0A" . T.replace "\t" "%09" . T.replace "%" "%25"
    182             unless (all (\(n,_,_) -> M.member n groups) (rLocals receipt)) $ fail "receipt lacks local interface pins"
    183             forM_ (M.toList groups) $ \(ref,ps) -> do
    184               m <- case ps of
    185                 (p,_,_):_ -> pure p
    186                 [] -> fail "empty receipt pin group"
    187               unless (all (\(p,_,_) -> p == m) ps) $ fail "conflicting pin group"
    188               if ref == m then either fail pure (validateObjectHash ref)
    189                 else unless ((ref,m) `elem` [(n,p) | (n,p,_) <- rLocals receipt]) $ fail "unbound receipt pin group"
    190               interface <- moduleObjects m
    191               let expected = M.fromList [(escape (moduleExportName ex),objectRefHash (moduleExportObject ex)) | ex <- moduleManifestExports interface]
    192               unless (M.fromList [(n,t) | (_,n,t) <- ps] == expected) $ fail "receipt pins differ from complete interface"
    193             pure (M.insert name (pin,receipt) children)
    194   receipts <- walk [] M.empty (recipeEntry initial) (recipeReceipt initial)
    195   let actualUnits = [(n,p) | (n,(p,_)) <- M.toAscList receipts]
    196   unless (null (recipeUnits initial) || recipeUnits initial == actualUnits) $ fail "recipe differs from reachable receipt graph"
    197   case recipeProject initial of
    198     Nothing -> pure ()
    199     Just (p,l) -> do
    200       ds <- get (Domain "arboricx.project-declaration.v1") p >>= either fail pure . J.decodeProject
    201       ls <- get (Domain "arboricx.project-lock.v1") l >>= either fail pure . J.decodeProjectLock
    202       let selection (n,locked) = let r = P.lockRelease locked in J.Dependency n (P.lockRegistry locked) (P.releaseName r) (P.releaseVersion r)
    203       unless (ds == map selection ls) $ fail "captured project declaration differs from lock"
    204       forM_ ls $ \(n,locked) -> case M.lookup n receipts of
    205         Nothing -> pure () -- unused project selections are context, not build inputs
    206         Just (_,r) -> do
    207           bytes <- get sourceDomain (rSource r)
    208           unless (bytes == P.bindingBytes (P.releaseManifest (P.lockRelease locked))) $ fail "project binding snapshot differs from lock"
    209   allObjects <- readIORef objects
    210   let external = S.fromList [m | (_,r) <- M.elems receipts, (ref,m,_,_) <- rPins r, ref == m]
    211   pure (Closure (initial {recipeUnits = actualUnits}) allObjects receipts external)
    212 
    213 loadStore :: StorePath -> Domain -> Text -> IO BS.ByteString
    214 loadStore store domain pin = do
    215   either fail pure (validateObjectHash pin)
    216   forM_ [unStorePath store, unStorePath store </> "objects", unStorePath store </> "objects" </> shardForHash pin] $ \dir -> do
    217     status <- getSymbolicLinkStatus dir
    218     unless (isDirectory status) $ fail "build inputs require real CAS directories (no symlinks)"
    219   let limit | domain == receiptDomain || domain == sourceDomain = 1048576
    220             | domain == manifestDomain || domain == Domain "arboricx.project-declaration.v1" || domain == Domain "arboricx.project-lock.v1" = 65536
    221             | otherwise = 64 * 1024 * 1024
    222   bytes <- bounded limit (objectPath store pin)
    223   either fail pure (verifyObjectBytes domain pin bytes)
    224   pure bytes
    225 
    226 -- Decoding executable graphs is shared with the read-only inspection machinery.
    227 validateExecutables :: StorePath -> Closure -> IO ()
    228 validateExecutables store c = forM_ [p | ((d,p),_) <- M.toList (cObjects c), d == unDomain manifestDomain] $ \p -> do
    229   evidence <- I.inspectModule store p
    230   unless (I.inspectionStatus evidence == "verified") $ fail ("unverified build module " ++ T.unpack p ++ ": " ++ T.unpack (I.inspectionStatus evidence))
    231 
    232 writeObjects :: StorePath -> M.Map (Text,Text) BS.ByteString -> IO ()
    233 writeObjects store objects = forM_ (M.toList objects) $ \((d,p),bytes) -> do
    234   actual <- putObject store (Domain d) bytes
    235   unless (actual == p) $ fail "internal object copy identity mismatch"
    236 
    237 cacheReceipt :: StorePath -> Text -> IO Text
    238 cacheReceipt (StorePath store) name = do
    239   bytes <- bounded 4096 (store </> "cache/source-build" </> T.unpack name)
    240   txt <- either (fail . show) pure (decodeUtf8' bytes)
    241   case T.splitOn "\t" txt of
    242     ["arboricx.source-build-cache.v1",key,out,receipt] -> do
    243       mapM_ (either fail pure . validateObjectHash) [key,out,receipt]
    244       receiptBytes <- loadStore (StorePath store) receiptDomain receipt
    245       parsed <- either fail pure (parseReceipt receiptBytes)
    246       txtReceipt <- either (fail . show) pure (decodeUtf8' receiptBytes)
    247       let body = drop 1 (T.lines txtReceipt)
    248           keyBytes = encodeUtf8 (T.unlines ("arboricx.source-build-key.v1" : take 3 body ++ drop 4 body))
    249       unless (rOutput parsed == out && hashObject (Domain "arboricx.source-build-key.v1") keyBytes == key) $
    250         fail "native cache locator differs from authenticated receipt"
    251       pure receipt
    252     _ -> fail "invalid native build cache locator"
    253 
    254 capture :: FilePath -> StorePath -> StorePath -> Text -> Int -> Maybe Text -> Maybe (FilePath,FilePath) -> FilePath -> IO Text
    255 capture file app tools compiler ioVersion selectedReceipt project output = do
    256   unless (ioVersion `elem` [1,2]) $ fail "compiler IO version must be 1 or 2"
    257   unless (takeExtension file == ".tri" && J.validBinding (T.pack (takeBaseName file))) $ fail "capture requires a native .tri entry"
    258   let entry = T.pack (takeBaseName file)
    259   sources <- workspaceSources file
    260   receipt <- maybe (cacheReceipt app entry) (\p -> either fail pure (validateObjectHash p) >> pure p) selectedReceipt
    261   context <- case project of
    262     Nothing -> pure Nothing
    263     Just (p,l) -> do
    264       ds <- bounded 65536 p
    265       ls <- bounded 65536 l
    266       pure (Just (ds,ls))
    267   let ctx = fmap (\(d,l) -> (hashObject (Domain "arboricx.project-declaration.v1") d,hashObject (Domain "arboricx.project-lock.v1") l)) context
    268       load d p | d == optionsDomain = pure profile
    269                | d == Domain "arboricx.project-declaration.v1", Just (bytes,_) <- context = pure bytes
    270                | d == Domain "arboricx.project-lock.v1", Just (_,bytes) <- context = pure bytes
    271                | otherwise = loadStore app d p
    272   -- Toolchain objects may live separately from application inputs. Copy them to
    273   -- the closure through a typed lookup, never an ambient alias or source fallback.
    274   toolManifestBytes <- loadStore tools manifestDomain compiler
    275   toolManifest <- either fail pure (decodeManifest toolManifestBytes)
    276   let toolPins = S.fromList ((unDomain manifestDomain,compiler) : [(objectRefKind r,objectRefHash r) | ex <- moduleManifestExports toolManifest, let r = moduleExportObject ex])
    277       combined d p = if S.member (unDomain d,p) toolPins then loadStore tools d p else load d p
    278   c <- collect (Recipe entry receipt compiler ioVersion [] ctx) combined (\name _ -> maybe (fail "receipt source absent from workspace") (bounded 1048576) (M.lookup (T.unpack name) sources))
    279   -- Publication is create-only; a failed capture may leave an incomplete directory
    280   -- with no descriptor. Never replace or merge another pack.
    281   createDirectoryIfMissing True (takeDirectory output)
    282   createDirectory output
    283   let packed = StorePath (output </> "store")
    284   writeObjects packed (cObjects c)
    285   validateExecutables packed c
    286   let bytes = encodeRecipe (cRecipe c)
    287   _ <- either fail pure (decodeRecipe bytes)
    288   P.writeAtomic True (output </> "build-inputs") bytes
    289   pure (hashObject (recipeDomain ioVersion) bytes)
    290 
    291 readPack :: FilePath -> Text -> IO Closure
    292 readPack directory pin = do
    293   bytes <- bounded 65536 (directory </> "build-inputs")
    294   ioVersion <- either fail pure (recipeVersion bytes)
    295   either fail pure (verifyObjectBytes (recipeDomain ioVersion) pin bytes)
    296   recipe <- either fail pure (decodeRecipe bytes)
    297   let store = StorePath (directory </> "store")
    298   c <- collect recipe (loadStore store) (\_ source -> loadStore store sourceDomain source)
    299   validateExecutables store c
    300   pure c
    301 
    302 verifyPack :: FilePath -> Text -> IO Text
    303 verifyPack directory pin = do
    304   c <- readPack directory pin
    305   pure ("Verified retained bytes and native receipt links (referenced closure only). Producer claims only; no build executed.\n"
    306     <> "Inputs " <> pin <> "\nNative receipt " <> recipeReceipt (cRecipe c)
    307     <> "\nCompiler " <> recipeCompiler (cRecipe c)
    308     <> "\nCompiler IO version " <> T.pack (show (recipeIOVersion (cRecipe c))) <> "\n")
    309 
    310 -- Prepare only explicitly needed executable inputs. Claimed outputs and receipts
    311 -- remain in the pack, not the fresh build store. No producer caches are copied.
    312 prepareRebuild :: FilePath -> Text -> Text -> Int -> FilePath -> IO Recipe
    313 prepareRebuild directory pin trusted trustedIO output = do
    314   c <- readPack directory pin
    315   let recipe = cRecipe c
    316   unless (trusted == recipeCompiler recipe) $ fail "--trust-compiler must equal the retained compiler manifest; verification alone does not authorize execution"
    317   unless (trustedIO == recipeIOVersion recipe) $ fail "--trust-io-version must equal the retained compiler IO version; v2 uses OS process authority"
    318   createDirectoryIfMissing True (takeDirectory output)
    319   createDirectory output
    320   let workspace = output </> "workspace"
    321       objects = cObjects c
    322       obj d p = maybe (fail "missing retained object") pure (M.lookup (unDomain d,p) objects)
    323       seed target manifest = do
    324         bytes <- obj manifestDomain manifest
    325         m <- either fail pure (decodeManifest bytes)
    326         _ <- putObject target manifestDomain bytes
    327         forM_ (moduleManifestExports m) $ \ex -> do
    328           let ref = moduleExportObject ex; d = Domain (objectRefKind ref)
    329           payload <- obj d (objectRefHash ref)
    330           _ <- putObject target d payload
    331           pure ()
    332   createDirectory workspace
    333   -- Replay must never inherit source mappings from the output directory's ancestors.
    334   BS.writeFile (workspace </> "tricu.workspace") "tricu.workspace.v1\n"
    335   forM_ (M.toList (cReceipts c)) $ \(name,(_,r)) -> obj sourceDomain (rSource r) >>= BS.writeFile (workspace </> T.unpack name ++ ".tri")
    336   case recipeProject recipe of
    337     Nothing -> pure ()
    338     Just (p,l) -> do
    339       obj (Domain "arboricx.project-declaration.v1") p >>= BS.writeFile (workspace </> "tricu.project")
    340       obj (Domain "arboricx.project-lock.v1") l >>= BS.writeFile (workspace </> "tricu.lock")
    341   seed (StorePath (output </> "tools")) (recipeCompiler recipe)
    342   forM_ (S.toList (cExternal c)) (seed (StorePath (output </> "application")))
    343   pure recipe
    344 
    345 finishRebuild :: FilePath -> Text -> FilePath -> Recipe -> Text -> Text -> IO (Text,Bool)
    346 finishRebuild directory pin output recipe actual actualReceipt = do
    347   c <- readPack directory pin
    348   unless (recipe == cRecipe c) $ fail "build input pack changed during rebuild"
    349   let expected = rOutput (snd (cReceipts c M.! recipeEntry recipe))
    350       app = StorePath (output </> "application")
    351   receiptBytes <- loadStore app receiptDomain actualReceipt
    352   r <- either fail pure (parseReceipt receiptBytes)
    353   unless (rOutput r == actual) $ fail "rebuilt receipt/output mismatch"
    354   -- Authenticate the returned receipt's entire newly produced local graph,
    355   -- using fresh sources/stores, not the expected receipts retained in the pack.
    356   let tools = StorePath (output </> "tools")
    357       workspace = output </> "workspace"
    358   toolBytes <- loadStore tools manifestDomain (recipeCompiler recipe)
    359   toolManifest <- either fail pure (decodeManifest toolBytes)
    360   let toolPins = S.fromList ((unDomain manifestDomain,recipeCompiler recipe) :
    361         [(objectRefKind ref,objectRefHash ref) | ex <- moduleManifestExports toolManifest, let ref = moduleExportObject ex])
    362       load d p
    363         | S.member (unDomain d,p) toolPins = loadStore tools d p
    364         | d == optionsDomain = pure profile
    365         | d == Domain "arboricx.project-declaration.v1" = bounded 65536 (workspace </> "tricu.project")
    366         | d == Domain "arboricx.project-lock.v1" = bounded 65536 (workspace </> "tricu.lock")
    367         | otherwise = loadStore app d p
    368   actualClosure <- collect (recipe {recipeReceipt = actualReceipt, recipeUnits = []}) load
    369     (\name _ -> bounded 1048576 (workspace </> T.unpack name ++ ".tri"))
    370   forM_ (S.toList (cExternal actualClosure `S.union` S.fromList [rOutput unit | (_,unit) <- M.elems (cReceipts actualClosure)])) $ \m -> do
    371     evidence <- I.inspectModule app m
    372     unless (I.inspectionStatus evidence == "verified") $ fail "rebuilt receipt graph contains an unverified executable module"
    373   before <- I.inspectModule (StorePath (directory </> "store")) expected
    374   after <- I.inspectModule app actual
    375   let names i = either (const M.empty) (M.fromList . map (\ex -> (moduleExportName ex,ex)) . moduleManifestExports) (I.inspectedManifest i)
    376       left = names before; right = names after
    377       rows = ["export " <> I.safeText name <> ": " <> compareExport name <> "\n" | name <- S.toAscList (M.keysSet left `S.union` M.keysSet right)]
    378       compareExport name = case (M.lookup name left,M.lookup name right) of
    379         (Nothing,_) -> "added"
    380         (_,Nothing) -> "removed"
    381         (Just a,Just b) | moduleExportAbi a /= moduleExportAbi b -> "ABI changed"
    382         _ -> case (M.lookup name (I.inspectedExports before),M.lookup name (I.inspectedExports after)) of
    383           (Just (Right a),Just (Right b)) -> either (const "unknown") (\same -> if same then "tree identical" else "tree different") (I.compareGraphs a b)
    384           _ -> "unknown"
    385       exact = actual == expected && actualReceipt == recipeReceipt recipe && I.inspectionStatus before == "verified" && I.inspectionStatus after == "verified"
    386       report = (if exact then "Reproduced native module and receipt exactly.\n" else "Rebuild mismatch; not reproduced exactly.\n")
    387         <> "Inputs " <> pin <> "\nCompiler IO version " <> T.pack (show (recipeIOVersion recipe)) <> "\nExpected module " <> expected <> "\nActual module " <> actual
    388         <> "\nExpected receipt " <> recipeReceipt recipe <> "\nActual receipt " <> actualReceipt <> "\n" <> T.concat rows
    389         <> "Local observation, not publisher authentication or a signed attestation.\n"
    390   P.writeAtomic True (output </> "rebuild.txt") (encodeUtf8 report)
    391   pure (report,exact)