# Anubis WASM vendor binary: reproducible builds are surprisingly hard

A build log by [[xe-iaso|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-wasm-vendor-binary|Anubis]] is adding [[webassembly|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|reproducible builds]] problem, and by extension a [[supply-chain-security|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
