Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions scripts/guest_rustflags.py
Original file line number Diff line number Diff line change
@@ -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.<triple>].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 <guest-dir> <workspace-root> <sysroot>\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()
Loading