Map
โ†‘Reproducible Builds

Anubis WASM vendor binary: reproducible builds are surprisingly hard

Wiki summaryreproducible-buildswebassemblycompilerssupply-chain โ†ณ show in map Markdown
title
Anubis WASM vendor binary: reproducible builds are surprisingly hard
type
summary
summary
Three ways the compiler broke determinism while vendoring a wasm2js binary for Anubis
tags
reproducible-builds, webassembly, compilers, supply-chain
created
2026-07-21
updated
2026-07-21

A build log by Xe Iaso (xeiaso.net, 2026-06-18) about producing one small vendored binary that builds bit-for-bit identically, and the three separate ways the toolchain refused to cooperate. The framing line: "You'd think that given the same bytes of input you'd get the same bytes of output. lol. lmao. No, you don't."

Why the binary exists

Anubis is adding WebAssembly proof-of-work checks so administrators can protect sites with a non-SHA256 challenge. A design goal is that the check logic is defined once and shared by client and server, which hook into the same Wasm so they run in lockstep. The catch: some browsers have Wasm disabled, and Iaso didn't want to lock those users out. The fix, borrowed from the talk The Birth and Death of JavaScript, is to recompile the Wasm to JavaScript with wasm2js from the binaryen project โ€” slower, but it runs everywhere.

Linux distributions ship ancient wasm2js versions that produce different output from the developer's Homebrew copy, so Anubis bundles its own wasm2js, built as Wasm with wasi-sdk and committed to the repo. For packagers to trust a committed binary, they have to be able to rebuild it and get the same bytes โ€” which turns this into a reproducible builds problem, and by extension a supply-chain one.

Three sources of nondeterminism

Build-time macros. __DATE__ and __TIME__ stamp the compile's wall-clock time into the output, so two builds seconds apart differ.

clang shelling out to $PATH. clang silently invokes wasm-opt from $PATH during the build. An aarch64 DGX Spark had wasm-opt v108; the x86_64 workstation had v130 from Homebrew. The old version choked on WebAssembly Exceptions instructions and failed the build outright. Fix: pass --no-wasm-opt at the link step to cut the hidden dependency.

Address-dependent codegen. clang's exception-handling path leaked raw pointer values into the order it emitted try_table blocks, so each build differed by about 29 bytes and the catch_all_ref indices shifted. The computation was identical; only the byte ordering moved. This also differs between x86_64 and arm64 because pointer iteration order isn't the same across architectures.

Fixes and the remaining gap

Two mitigations for the address-dependent codegen: disable ASLR with setarch --addr-no-randomize so pointers land consistently, and record known-good sha256 checksums per architecture, with a CI job verifying the rebuild matches on both x86_64 and arm64 runners. That gets reproducibility within an architecture. Cross-architecture reproducibility stays blocked by an upstream LLVM bug โ€” the pointer iteration order isn't seeded โ€” which Iaso flagged for anyone working on LLVM. "Deterministic within an architecture" had to be good enough for now.

See also

  • reproducible-builds โ€” the general concept and the failure modes this post catalogs
  • dependency-vendoring โ€” why committing a prebuilt binary needs reproducibility to be trustworthy
  • webassembly โ€” Wasm, the Core Spec, and the wasm2js fallback
  • wazero โ€” the pure-Go Wasm runtime, which (like wasmtime) requires flagging on exception support
  • reuse-less-software โ€” the broader vendoring argument this fits into
  • supply-chain-security โ€” the trust chain a verifiable committed binary protects