From a1fea29e6275d22396b498d2722ec0a497e7cd9b Mon Sep 17 00:00:00 2001 From: Damian Reeves <957246+DamianReeves@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:13:26 -0600 Subject: [PATCH 1/2] fix(extensions): keep build machine paths out of the WASM guests The WASM of extension/rust/v0.4.0 built on macOS differed from the release job's build. Exactly two things caused that, which a rebuild of 0716e22 here proved: with the release job's paths and its host line reported to cargo, the bytes equal the published WASM. This fixes the first cause. Guests embedded the Cargo home and, with rust-src installed, the toolchain directory, so a local build recorded the builder's home path. The rust and elm-native artifact tasks now remap both, for the guest build only, and fail if a guest still contains either directory. The second cause stays: cargo hashes the host triple into crate metadata through build scripts and proc macros, so different host triples still give different bytes. Document this, so a local digest is not mistaken for a prediction of the published one. Bead morphir-0xtc.5. --- .mise/tasks/extension/artifact/elm-native | 32 +++++++++- .mise/tasks/extension/artifact/rust | 32 +++++++++- CHANGELOG.md | 5 ++ docs/extensions/capability-claims.md | 18 ++++++ tests/ci/package_extension_test_support.py | 17 +++++ tests/ci/test_package_extension_task.py | 74 ++++++++++++++++++++++ 6 files changed, 176 insertions(+), 2 deletions(-) diff --git a/.mise/tasks/extension/artifact/elm-native b/.mise/tasks/extension/artifact/elm-native index 3f40c2d9..c49185be 100755 --- a/.mise/tasks/extension/artifact/elm-native +++ b/.mise/tasks/extension/artifact/elm-native @@ -143,6 +143,34 @@ require_snapshot_inputs() { fi } +# A guest must not record where it was built. Without these flags it embeds the builder's Cargo home +# and, when the toolchain has rust-src, the path of the toolchain. The second flag gives the path that +# rustc records when rust-src is absent, so that component makes no difference. The flags apply only to +# the guest build: CARGO_ENCODED_RUSTFLAGS keeps a path with spaces whole, and with --target it does not +# reach build scripts and proc macros. Cargo still mixes the build machine's host triple into crate hashes +# through those units, so builds on different host triples give different bytes. See "Build paths and +# digests" in docs/extensions/capability-claims.md. +guest_path_flags() { + sysroot="$(rustc --print sysroot)" + commit="$(rustc -vV | sed -n 's/^commit-hash: //p')" + if [ -z "$sysroot" ] || [ -z "$commit" ]; then + echo "cannot read the Rust sysroot and commit hash" >&2 + return 1 + fi + printf '%s\037%s' \ + "--remap-path-prefix=${CARGO_HOME:-$HOME/.cargo}=/cargo" \ + "--remap-path-prefix=$sysroot/lib/rustlib/src/rust=/rustc/$commit" +} + +refuse_machine_paths() { + for machine_path in "${CARGO_HOME:-$HOME/.cargo}/" "$(rustc --print sysroot)/lib/rustlib/"; do + if LC_ALL=C grep -aqF "$machine_path" "$1"; then + echo "$1 embeds a build machine path: $machine_path" >&2 + return 1 + fi + done +} + run_pipeline() { source_root="$1" output="$2" @@ -150,7 +178,9 @@ run_pipeline() { ( cd "$source_root" cargo test --locked -p morphir-elm-binding - cargo build --locked --release -p morphir-elm-binding --target wasm32-unknown-unknown + path_flags="$(guest_path_flags)" + CARGO_ENCODED_RUSTFLAGS="$path_flags" cargo build --locked --release -p morphir-elm-binding --target wasm32-unknown-unknown + refuse_machine_paths "$WASM" wasm-tools validate "$WASM" MORPHIR_ELM_GUEST="$WASM" cargo test --locked -p morphir-elm-binding --features wasm-host-tests --test wasm -- --ignored set -- scripts/package_extension.py \ diff --git a/.mise/tasks/extension/artifact/rust b/.mise/tasks/extension/artifact/rust index f437dc4c..361022c9 100755 --- a/.mise/tasks/extension/artifact/rust +++ b/.mise/tasks/extension/artifact/rust @@ -101,6 +101,34 @@ require_snapshot_inputs() { fi } +# A guest must not record where it was built. Without these flags it embeds the builder's Cargo home +# and, when the toolchain has rust-src, the path of the toolchain. The second flag gives the path that +# rustc records when rust-src is absent, so that component makes no difference. The flags apply only to +# the guest build: CARGO_ENCODED_RUSTFLAGS keeps a path with spaces whole, and with --target it does not +# reach build scripts and proc macros. Cargo still mixes the build machine's host triple into crate hashes +# through those units, so builds on different host triples give different bytes. See "Build paths and +# digests" in docs/extensions/capability-claims.md. +guest_path_flags() { + sysroot="$(rustc --print sysroot)" + commit="$(rustc -vV | sed -n 's/^commit-hash: //p')" + if [ -z "$sysroot" ] || [ -z "$commit" ]; then + echo "cannot read the Rust sysroot and commit hash" >&2 + return 1 + fi + printf '%s\037%s' \ + "--remap-path-prefix=${CARGO_HOME:-$HOME/.cargo}=/cargo" \ + "--remap-path-prefix=$sysroot/lib/rustlib/src/rust=/rustc/$commit" +} + +refuse_machine_paths() { + for machine_path in "${CARGO_HOME:-$HOME/.cargo}/" "$(rustc --print sysroot)/lib/rustlib/"; do + if LC_ALL=C grep -aqF "$machine_path" "$1"; then + echo "$1 embeds a build machine path: $machine_path" >&2 + return 1 + fi + done +} + run_pipeline() { source_root="$1" output="$2" @@ -108,7 +136,9 @@ run_pipeline() { ( cd "$source_root" cargo test --locked -p morphir-rust-binding - cargo build --locked --release -p morphir-rust-binding --target wasm32-unknown-unknown + path_flags="$(guest_path_flags)" + CARGO_ENCODED_RUSTFLAGS="$path_flags" cargo build --locked --release -p morphir-rust-binding --target wasm32-unknown-unknown + refuse_machine_paths "$WASM" wasm-tools validate "$WASM" MORPHIR_RUST_GUEST="$WASM" cargo test --locked -p morphir-rust-binding --features wasm-host-tests --test wasm -- --ignored set -- scripts/package_extension.py \ diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c540a8..8ab81f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -392,6 +392,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and `ProcessChild::read_within`, or a `ProcessChannel`. ### Fixed +- Extension WASM guests no longer record the build machine's Cargo home or + toolchain directory. `extension:artifact:rust` and `extension:artifact:elm-native` + remap those paths and fail if a guest still contains them. Builds of one commit + on one host triple now give the same bytes; builds on different host triples + still differ, because Cargo hashes the host triple into crate metadata. - Concurrent first cache-maintenance saves and mutation leases share one coordination file without macOS `NotFound` failures. Atomic creation and existing-file opens retain directory confinement and no-follow checks. diff --git a/docs/extensions/capability-claims.md b/docs/extensions/capability-claims.md index 78f5351c..ea143521 100644 --- a/docs/extensions/capability-claims.md +++ b/docs/extensions/capability-claims.md @@ -77,6 +77,24 @@ The claimed extension version must match the registered crate's `Cargo.toml`. `.github/extensions.toml` remains the declared source of release capabilities; packaging never replaces a guest claim with a registry value. +### Build paths and digests + +The `rust` and `elm-native` tasks build the guest with `--remap-path-prefix`, so +it does not record the builder's Cargo home or toolchain directory. Paths under +the Cargo home become `/cargo/...`, and standard library paths become +`/rustc//...`, with or without the `rust-src` component. The task fails +if the guest still contains either directory. + +One commit and toolchain give the same WASM on machines with the same host +triple. Machines with different host triples give different bytes: Cargo hashes +the `host:` line of `rustc -vV` for build scripts and proc macros, and that hash +reaches every crate that depends on them, which changes the guest's mangled +symbols. So a run of the task on macOS checks the release steps, but its WASM +digest differs from the release job's, which runs on +`x86_64-unknown-linux-gnu`. Run the task on that host triple to predict the +published digest. `release.json` records the commit and the WASM digest, so its +own digest follows them. Qualify the published assets, not a local build. + A bundle descriptor has this structure. The digest below is illustrative: ```json diff --git a/tests/ci/package_extension_test_support.py b/tests/ci/package_extension_test_support.py index 11afb9e8..90e87aef 100644 --- a/tests/ci/package_extension_test_support.py +++ b/tests/ci/package_extension_test_support.py @@ -285,7 +285,24 @@ def __init__(self, root: Path) -> None: 'build --locked --release -p morphir-rust-binding --target wasm32-unknown-unknown') mkdir -p "$CARGO_TARGET_DIR/wasm32-unknown-unknown/release" printf '\\000asm\\001\\000\\000\\000' > "$CARGO_TARGET_DIR/wasm32-unknown-unknown/release/morphir_rust_binding.wasm" + printf '%s' "${CARGO_ENCODED_RUSTFLAGS:-}" > "$ARTIFACT_TASK_LOG.wasm-flags" + printf '%s' "${ARTIFACT_EMBEDDED_PATH:-}" >> "$CARGO_TARGET_DIR/wasm32-unknown-unknown/release/morphir_rust_binding.wasm" ;; + *) + if [ -n "${CARGO_ENCODED_RUSTFLAGS:-}" ]; then + printf '%s\n' "cargo $*" >> "$ARTIFACT_TASK_LOG.leaked-flags" + fi + ;; + esac + """, + ) + self._write_tool( + "rustc", + """ + case "$*" in + '--print sysroot') printf '%s\n' "${ARTIFACT_SYSROOT-/fixture/sysroot}" ;; + '-vV') printf 'rustc 1.0.0\ncommit-hash: %s\nhost: fixture\n' "${ARTIFACT_RUSTC_COMMIT-abc123}" ;; + *) exit 1 ;; esac """, ) diff --git a/tests/ci/test_package_extension_task.py b/tests/ci/test_package_extension_task.py index c4cab759..14cde6fe 100644 --- a/tests/ci/test_package_extension_task.py +++ b/tests/ci/test_package_extension_task.py @@ -84,6 +84,80 @@ def test_staging_cleanup_io_error_has_no_traceback(self) -> None: self.assertNotIn("Traceback", result.stderr) self.assertFalse(fixture.log.exists()) + def test_the_guest_build_remaps_machine_paths_and_native_builds_do_not(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + fixture = ArtifactTaskFixture(Path(temporary_directory) / "repository") + + result = fixture.run({"CARGO_HOME": "/fixture/cargo home"}) + + self.assertEqual(0, result.returncode, result.stderr) + flags = Path(f"{fixture.log}.wasm-flags").read_text(encoding="utf-8") + self.assertEqual( + [ + "--remap-path-prefix=/fixture/cargo home=/cargo", + "--remap-path-prefix=/fixture/sysroot/lib/rustlib/src/rust=/rustc/abc123", + ], + flags.split("\x1f"), + ) + self.assertFalse(Path(f"{fixture.log}.leaked-flags").exists()) + + def test_cargo_home_defaults_to_the_directory_cargo_uses(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + fixture = ArtifactTaskFixture(Path(temporary_directory) / "repository") + environment = {"HOME": "/fixture/home", "CARGO_HOME": ""} + + result = fixture.run(environment) + + self.assertEqual(0, result.returncode, result.stderr) + flags = Path(f"{fixture.log}.wasm-flags").read_text(encoding="utf-8") + self.assertIn("--remap-path-prefix=/fixture/home/.cargo=/cargo", flags) + + def test_a_guest_that_embeds_a_machine_path_fails_before_packaging(self) -> None: + for embedded in ("/fixture/cargo/registry/src/a.rs", "/fixture/sysroot/lib/rustlib/src/rust/b.rs"): + with self.subTest(embedded=embedded), tempfile.TemporaryDirectory() as temporary_directory: + fixture = ArtifactTaskFixture(Path(temporary_directory) / "repository") + + result = fixture.run( + {"CARGO_HOME": "/fixture/cargo", "ARTIFACT_EMBEDDED_PATH": embedded} + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("embeds a build machine path", result.stderr) + self.assertFalse(any((fixture.root / ".morphir/build/extensions/rust").glob("*.wasm"))) + + def test_an_unknown_toolchain_fails_instead_of_building_without_remapping(self) -> None: + for environment in ({"ARTIFACT_SYSROOT": ""}, {"ARTIFACT_RUSTC_COMMIT": ""}): + with self.subTest(environment=environment), tempfile.TemporaryDirectory() as temporary_directory: + fixture = ArtifactTaskFixture(Path(temporary_directory) / "repository") + + result = fixture.run(environment) + + self.assertNotEqual(0, result.returncode) + self.assertFalse(Path(f"{fixture.log}.wasm-flags").exists()) + + def test_every_artifact_task_builds_its_guest_with_the_same_path_rules(self) -> None: + for short_id, package in (("rust", "morphir-rust-binding"), ("elm-native", "morphir-elm-binding")): + with self.subTest(short_id=short_id): + script = (RUST_ARTIFACT_TASK.parent / short_id).read_text(encoding="utf-8") + self.assertIn( + f'CARGO_ENCODED_RUSTFLAGS="$path_flags" cargo build --locked --release -p {package}' + " --target wasm32-unknown-unknown", + script, + ) + self.assertLess( + script.index('CARGO_ENCODED_RUSTFLAGS="$path_flags"'), + script.index('refuse_machine_paths "$WASM"'), + ) + self.assertLess( + script.index('refuse_machine_paths "$WASM"'), + script.index("set -- scripts/package_extension.py"), + ) + def rules(short_id: str) -> str: + script = (RUST_ARTIFACT_TASK.parent / short_id).read_text(encoding="utf-8") + return script[script.index("guest_path_flags() {"):script.index("run_pipeline() {")] + + self.assertEqual(rules("rust"), rules("elm-native")) + def test_clean_tree_records_the_head_commit(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: fixture = ArtifactTaskFixture(Path(temporary_directory) / "repository") From 52bfecc8796dae997890739cedd27f7095d9199d Mon Sep 17 00:00:00 2001 From: Damian Reeves <957246+DamianReeves@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:20:06 -0600 Subject: [PATCH 2/2] fix(extensions): accept a canonical Cargo home in the guest path check With CARGO_HOME=/cargo the remap changes nothing, so a correctly mapped /cargo/registry path matched the guard and packaging refused a valid guest. Skip the Cargo home check in that case; the toolchain check still runs. A trailing slash on CARGO_HOME had the opposite effect: the remap gave /cargoregistry paths and the guard searched for a double slash that never matches, so it could not fail. Normalize the home before both. Found by the Codex review; the trailing slash was found while checking it. --- .mise/tasks/extension/artifact/elm-native | 11 +++++-- .mise/tasks/extension/artifact/rust | 11 +++++-- tests/ci/test_package_extension_task.py | 38 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/.mise/tasks/extension/artifact/elm-native b/.mise/tasks/extension/artifact/elm-native index c49185be..d1bf1659 100755 --- a/.mise/tasks/extension/artifact/elm-native +++ b/.mise/tasks/extension/artifact/elm-native @@ -150,6 +150,11 @@ require_snapshot_inputs() { # reach build scripts and proc macros. Cargo still mixes the build machine's host triple into crate hashes # through those units, so builds on different host triples give different bytes. See "Build paths and # digests" in docs/extensions/capability-claims.md. +cargo_home() { + home="${CARGO_HOME:-$HOME/.cargo}" + printf '%s' "${home%/}" +} + guest_path_flags() { sysroot="$(rustc --print sysroot)" commit="$(rustc -vV | sed -n 's/^commit-hash: //p')" @@ -158,12 +163,14 @@ guest_path_flags() { return 1 fi printf '%s\037%s' \ - "--remap-path-prefix=${CARGO_HOME:-$HOME/.cargo}=/cargo" \ + "--remap-path-prefix=$(cargo_home)=/cargo" \ "--remap-path-prefix=$sysroot/lib/rustlib/src/rust=/rustc/$commit" } refuse_machine_paths() { - for machine_path in "${CARGO_HOME:-$HOME/.cargo}/" "$(rustc --print sysroot)/lib/rustlib/"; do + for machine_path in "$(cargo_home)/" "$(rustc --print sysroot)/lib/rustlib/"; do + # A Cargo home at /cargo is its own canonical path: every remapped path starts with it. + [ "$machine_path" = "/cargo/" ] && continue if LC_ALL=C grep -aqF "$machine_path" "$1"; then echo "$1 embeds a build machine path: $machine_path" >&2 return 1 diff --git a/.mise/tasks/extension/artifact/rust b/.mise/tasks/extension/artifact/rust index 361022c9..d1f30483 100755 --- a/.mise/tasks/extension/artifact/rust +++ b/.mise/tasks/extension/artifact/rust @@ -108,6 +108,11 @@ require_snapshot_inputs() { # reach build scripts and proc macros. Cargo still mixes the build machine's host triple into crate hashes # through those units, so builds on different host triples give different bytes. See "Build paths and # digests" in docs/extensions/capability-claims.md. +cargo_home() { + home="${CARGO_HOME:-$HOME/.cargo}" + printf '%s' "${home%/}" +} + guest_path_flags() { sysroot="$(rustc --print sysroot)" commit="$(rustc -vV | sed -n 's/^commit-hash: //p')" @@ -116,12 +121,14 @@ guest_path_flags() { return 1 fi printf '%s\037%s' \ - "--remap-path-prefix=${CARGO_HOME:-$HOME/.cargo}=/cargo" \ + "--remap-path-prefix=$(cargo_home)=/cargo" \ "--remap-path-prefix=$sysroot/lib/rustlib/src/rust=/rustc/$commit" } refuse_machine_paths() { - for machine_path in "${CARGO_HOME:-$HOME/.cargo}/" "$(rustc --print sysroot)/lib/rustlib/"; do + for machine_path in "$(cargo_home)/" "$(rustc --print sysroot)/lib/rustlib/"; do + # A Cargo home at /cargo is its own canonical path: every remapped path starts with it. + [ "$machine_path" = "/cargo/" ] && continue if LC_ALL=C grep -aqF "$machine_path" "$1"; then echo "$1 embeds a build machine path: $machine_path" >&2 return 1 diff --git a/tests/ci/test_package_extension_task.py b/tests/ci/test_package_extension_task.py index 14cde6fe..7b243697 100644 --- a/tests/ci/test_package_extension_task.py +++ b/tests/ci/test_package_extension_task.py @@ -125,6 +125,44 @@ def test_a_guest_that_embeds_a_machine_path_fails_before_packaging(self) -> None self.assertIn("embeds a build machine path", result.stderr) self.assertFalse(any((fixture.root / ".morphir/build/extensions/rust").glob("*.wasm"))) + def test_a_cargo_home_that_is_already_the_canonical_path_is_not_a_machine_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + fixture = ArtifactTaskFixture(Path(temporary_directory) / "repository") + + result = fixture.run( + {"CARGO_HOME": "/cargo", "ARTIFACT_EMBEDDED_PATH": "/cargo/registry/src/a.rs"} + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue(any((fixture.root / ".morphir/build/extensions/rust").glob("*.wasm"))) + + def test_the_canonical_cargo_home_does_not_hide_a_toolchain_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + fixture = ArtifactTaskFixture(Path(temporary_directory) / "repository") + + result = fixture.run( + { + "CARGO_HOME": "/cargo", + "ARTIFACT_EMBEDDED_PATH": "/fixture/sysroot/lib/rustlib/src/rust/b.rs", + } + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("embeds a build machine path", result.stderr) + + def test_a_trailing_slash_on_cargo_home_changes_nothing(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + fixture = ArtifactTaskFixture(Path(temporary_directory) / "repository") + + result = fixture.run( + {"CARGO_HOME": "/fixture/cargo/", "ARTIFACT_EMBEDDED_PATH": "/fixture/cargo/registry/a.rs"} + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("embeds a build machine path", result.stderr) + flags = Path(f"{fixture.log}.wasm-flags").read_text(encoding="utf-8") + self.assertIn("--remap-path-prefix=/fixture/cargo=/cargo\x1f", flags) + def test_an_unknown_toolchain_fails_instead_of_building_without_remapping(self) -> None: for environment in ({"ARTIFACT_SYSROOT": ""}, {"ARTIFACT_RUSTC_COMMIT": ""}): with self.subTest(environment=environment), tempfile.TemporaryDirectory() as temporary_directory: