diff --git a/Makefile b/Makefile index cf794e081..409ff268a 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ test-profile-recursion-block recursion-profile-block-input \ test-fast test-prover test-prover-all test-prover-debug test-disk-spill test-math-cuda test-cuda-integration test-cuda-d1 test-cuda-fallback \ test-prover-cuda test-prover-comprehensive-cuda \ bench-math-cuda bench-prover bench-prover-cuda build check clippy fmt lint regen-ethrex-fixtures \ -update-ethrex-fixture-checksums check-ethrex-fixture-checksums ethrex-real-block-fixture \ +update-ethrex-fixture-checksums check-ethrex-fixture-checksums check-ethrex-guest-elf ethrex-real-block-fixture \ ethrex-real-block-cache ethrex-real-block-converter-cache print-real-block-fixture \ print-real-block-fixture-url \ test-ethrex-real-block-converter regen-real-block-fixture @@ -194,10 +194,19 @@ FORCE: # presets below), the built binary's filename ($(2)), and optional extra cargo # args ($(3), e.g. `--features min`). cargo owns the dep graph (see FORCE # above), so the recipe always runs and lets cargo decide what to rebuild. +# ★ `CARGO_ENCODED_RUSTFLAGS` is the guest's OWN flags plus the path remappings, +# computed per guest by `scripts/guest_rustflags.py` — see that file for why it +# reads the flags out of each `.cargo/config.toml` instead of this recipe +# carrying one list (the guests' flag sets are not uniform, and this variable +# REPLACES the config's rather than extending it, so a blanket value would drop +# flags silently). Without the remappings the ELF embeds the absolute paths of +# the machine that built it, which makes every constant read off it — roots, +# genesis pages, cycle counts — partly a function of the filesystem. define build_guest_elf cd $(1) && \ CARGO_TARGET_DIR=$(abspath $(SHARED_TARGET_DIR)) \ CFLAGS_riscv64im_lambda_vm_elf="$(SYSROOT_CFLAGS)" \ + CARGO_ENCODED_RUSTFLAGS="$$(python3 $(abspath scripts/guest_rustflags.py) $(abspath $(1)) $(CURDIR) $(abspath $(SYSROOT_DIR)))" \ rustup run nightly-2026-02-01 cargo build --release \ --target $(RV64_TARGET_SPEC) \ -Z build-std=core,alloc,std,compiler_builtins,panic_abort \ @@ -322,6 +331,79 @@ ETHREX_REAL_BLOCK_ID := $(ETHREX_REAL_BLOCK_NETWORK)_$(ETHREX_REAL_BLOCK) ETHREX_REAL_BLOCK_FIXTURE := executor/tests/ethrex_$(ETHREX_REAL_BLOCK_ID).bin ETHREX_REAL_BLOCK_CACHE := tooling/ethrex-block-converter/caches/cache_$(ETHREX_REAL_BLOCK_ID).json +# ★ The BUILT ethrex guest's sha256 — the fixture's twin, for the other input a +# block proof depends on. +# +# The block fixture is pinned because it is fetched and could be the wrong file. +# The guest is pinned for the opposite reason: it is BUILT, so it looks like a +# function of the commit and is not obviously something that can drift. Its +# roots, its genesis page set and its cycle count are all read off it and become +# constants elsewhere, so a guest that differs is a set of constants that differ, +# with nothing in the tree to say so. +# +# It could drift until now. The ELF embedded the absolute paths of the machine +# that built it, so two checkouts of one commit produced different bytes and two +# machines differed by kilobytes. `scripts/guest_rustflags.py` remaps those away; +# this pin is what makes the result assertable rather than merely intended. +# +# ⚠ Regenerate deliberately, never to make the check pass. A miss means the +# guest changed — the ethrex rev, the syscalls crate, `ethrex-crypto`, the +# toolchain, or a flag — and every ELF-derived constant needs re-deriving with +# it. `make check-ethrex-guest-elf` prints both digests on a miss. +# +# ⛔ **PROVISIONAL, AND THE REASON IS MEASURED.** The remapping removes every +# path rustc controls — a build now has ZERO `/Users/...` strings — but the ELF +# is still not byte-identical across differently NAMED checkouts. Two worktrees +# of this commit on one machine gave 3,948,944 and 3,950,136 bytes. +# +# The residue is not a path in the binary, it is cargo's `-C metadata` +# disambiguator, which cargo derives from a package's absolute source path and +# passes to rustc before `--remap-path-prefix` can apply to anything. Measured +# across the two builds, the boundary is exact: +# +# ethrex (the guest crate, a path package) DIFFERS +# lambda-vm-ethrex-crypto (path dependency) DIFFERS +# ethrex-trie (git dependency) same +# core (build-std) same +# +# So it is path DEPENDENCIES only, and it is a cargo input rather than a rustc +# one. Closing it needs the build to happen at a canonical path — a container or +# a fixed mount — which is a bigger decision than a flag. +# +# ⇒ Until then this digest is a property of THIS commit built in a directory +# named `lambda_vm-remap`, not of the commit. Do not wire the check into `test` +# or `lint`; it is for a builder that controls its own path, which is what the +# campaign's fixture-by-sha workflow already is. +ETHREX_GUEST_ELF := $(RUST_ARTIFACTS_DIR)/ethrex.elf +ETHREX_GUEST_ELF_SHA256 := 3d34a312e15049a74741eec96232f479dedab9020a4851f20661f60bbce8a197 + +# $(call assert_sha256,file,want,label) — a CHECK, not a fetch. +# +# Separate from `ensure_verified` on purpose: there is nowhere to refetch a built +# artifact from, so the only useful behaviours are pass and a loud, specific +# failure. Missing file and wrong digest are distinguished, because they mean +# different things to whoever reads the line. +define assert_sha256 + @set -e; \ + f="$(1)"; want="$(2)"; \ + if command -v sha256sum >/dev/null 2>&1; then shacmd="sha256sum"; \ + elif command -v shasum >/dev/null 2>&1; then shacmd="shasum -a 256"; \ + else echo "$(3): missing sha256sum or shasum" >&2; exit 1; fi; \ + if [ ! -f "$$f" ]; then \ + echo "$(3): $$f is missing - build it first" >&2; exit 1; fi; \ + got=$$($$shacmd "$$f" | awk '{print $$1}'); \ + if [ "$$got" != "$$want" ]; then \ + echo "$(3): $$f" >&2; \ + echo " expected $$want" >&2; \ + echo " got $$got" >&2; \ + echo " The guest changed. Re-derive every constant read off it before repinning." >&2; \ + exit 1; fi; \ + echo "$(3): $$f matches $$want" +endef + +check-ethrex-guest-elf: + $(call assert_sha256,$(ETHREX_GUEST_ELF),$(ETHREX_GUEST_ELF_SHA256),ethrex guest ELF) + # $(call ensure_verified,url,sha256,dest,label,url-var-name) # # Guard-then-fetch, the same shape as prepare-sysroot above: the digest of whatever diff --git a/scripts/guest_rustflags.py b/scripts/guest_rustflags.py new file mode 100755 index 000000000..7dd78f23a --- /dev/null +++ b/scripts/guest_rustflags.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Emit a guest crate's rustflags with `--remap-path-prefix` appended, in +`CARGO_ENCODED_RUSTFLAGS` form. + +★ WHY THIS EXISTS. A guest ELF is a program constant: its Merkle roots, its +genesis page set and its cycle count are all read off it and pinned. Without +remapping, the ELF also embeds every absolute path of the machine that built it +— the workspace directory, the cargo registry checkout, the rustup toolchain, +the sysroot — so those constants are partly a function of the filesystem. Two +checkouts of one commit, on one machine, produce different ELFs; two machines +differ by kilobytes. That makes "same commit, same ELF" untrue and unassertable, +and a pin taken in one worktree is not a claim about another's guest. + +⚠ WHY A SCRIPT RATHER THAN A LINE IN EACH `.cargo/config.toml`. The prefixes +contain absolute paths that only the build knows, so they cannot be checked in. +The obvious alternative — `RUSTFLAGS` from the Makefile — is wrong: that +variable REPLACES `[target.].rustflags` rather than extending it, and +the guests' flag sets are not uniform (some carry `link-arg=-e main`, some +`getrandom_backend`, in four different combinations). A blanket value would +silently drop flags that decide whether a guest links or randomises correctly. + +So this reads each guest's OWN flags out of its config and appends to them. The +config stays the single source of truth; nothing is duplicated, and a guest that +gains a flag needs no change here. + +⛔ WHAT THIS DOES NOT FIX, measured rather than assumed. After remapping, an ELF +has zero `/Users/...` strings, but two differently NAMED checkouts of one commit +still differ (3,948,944 against 3,950,136 bytes on one machine). The residue is +cargo's `-C metadata` disambiguator: cargo derives it from a package's absolute +source path and hands it to rustc before any remapping applies. The boundary is +exact — the guest crate and `lambda-vm-ethrex-crypto`, both PATH packages, get +different disambiguators, while `ethrex-trie` (a git dependency) and `core` +(build-std) are identical. Closing that needs a canonical build path, not a +flag. + +What this DOES fix is the larger and more dangerous half: the ELF no longer +depends on the HOST. The home directory, the cargo registry location, the +sysroot and the rustup toolchain path — which carries the host triple, and so +differed between a Linux box and a macOS laptop in every one of the many strings +that embed it — are gone. That is what made "the same sources" produce 3,948,520 +on one machine and 3,952,608 on another. + +`CARGO_ENCODED_RUSTFLAGS` rather than `RUSTFLAGS` because the encoded form is +separated by `\\x1f` and needs no quoting: `--cfg getrandom_backend="custom"` +survives verbatim, where the space-separated form depends on the shell. +""" + +from __future__ import annotations + +import os +import sys +import tomllib +from pathlib import Path + +SEPARATOR = "\x1f" + + +def config_rustflags(guest_dir: Path, triple: str) -> list[str]: + """The guest's own flags, exactly as cargo would have applied them. + + Absent config or absent section is not an error: a guest with no flags of + its own still wants the remaps. + """ + config = guest_dir / ".cargo" / "config.toml" + if not config.is_file(): + return [] + with config.open("rb") as handle: + parsed = tomllib.load(handle) + target = parsed.get("target", {}).get(triple, {}) + flags = target.get("rustflags", []) + if not isinstance(flags, list) or not all(isinstance(f, str) for f in flags): + sys.exit(f"{config}: [target.{triple}].rustflags must be an array of strings") + return list(flags) + + +def remap_flags(prefixes: list[tuple[str, str]]) -> list[str]: + """One `--remap-path-prefix` per real prefix, longest first. + + ⚠ The order matters. rustc applies the LAST matching remapping, so a + shorter prefix listed later would win over a longer one that is more + specific — a sysroot inside the workspace, say, would come out labelled as + workspace rather than sysroot. Sorting longest-first and letting the last + (shortest) match lose is the stable rule; it also makes the output + independent of the order the caller happened to pass them in. + """ + out: list[str] = [] + for real, virtual in sorted(prefixes, key=lambda p: len(p[0]), reverse=True): + if not real: + continue + # No trailing separator: rustc matches on the raw string, and a path + # equal to the prefix itself should map too. + out.append(f"--remap-path-prefix={real.rstrip('/')}={virtual}") + return out + + +def main() -> None: + if len(sys.argv) != 4: + sys.exit( + "usage: guest_rustflags.py \n" + " CARGO_HOME and RUSTUP_HOME are read from the environment." + ) + guest_dir = Path(sys.argv[1]).resolve() + workspace = Path(sys.argv[2]).resolve() + sysroot = Path(sys.argv[3]).resolve() + + home = Path.home() + cargo_home = Path(os.environ.get("CARGO_HOME") or home / ".cargo").resolve() + # The build-std sources live under the rustup toolchain, and they are the + # BULK of the embedded paths — every `library/core/src/...` panic location. + # Omitting this one leaves the ELF host-specific while looking fixed. + rustup_home = Path(os.environ.get("RUSTUP_HOME") or home / ".rustup").resolve() + + flags = config_rustflags(guest_dir, "riscv64im-lambda-vm-elf") + flags += remap_flags( + [ + (str(workspace), "/lambda-vm"), + (str(cargo_home), "/cargo"), + (str(rustup_home), "/rustup"), + (str(sysroot), "/sysroot"), + ] + ) + sys.stdout.write(SEPARATOR.join(flags)) + + +if __name__ == "__main__": + main()