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)