From 6a312427798e41a4d02fa7db74374429fe612d22 Mon Sep 17 00:00:00 2001 From: Cesar Abel Date: Thu, 10 Sep 2026 16:03:50 -0600 Subject: [PATCH 1/2] feat(uv): reproducible rustc wrapper and cc-rs environment for Rust sdists The rustc wrapper only injected --sysroot, so a Rust wheel carried the sandbox and execroot paths of the host that built it (debug info, panic locations) and LLVM's per-host module ids in symbol names: the wheel action itself hits the remote cache, but its bytes differ per host, so every downstream action (whl_install, venvs) misses. The wrapper now remaps the sandbox root and the execroot away and compiles target crates as a single codegen unit; build scripts and proc-macros, which never reach the wheel, keep cargo's codegen. Native builds treat every crate as target. Crates with C or C++ sources (ring, zstd-sys) build them through cc-rs, which looks up CC_, CXX_, AR_ and RANLIB_ before falling back to the PATH; only CARGO_TARGET__LINKER was set, so those objects came from whatever compiler the runner had. Cross builds now export the wired C toolchain under both spellings, with a ranlib wrapper over `ar s` for toolchains that ship none. The linker stays the C driver: linking with c++ would add an implicit libstdc++ dependency to every Rust extension, not only the ones with C++ sources. Tests run the generated wrapper around an argv-echoing rustc and check the cc-rs variables end to end through the cross env. --- docs/uv.md | 8 ++ .../pep517_whl/tests/build_helper_test.py | 69 ++++++++++++++++- uv/private/pep517_whl/tools/build_helper.py | 75 +++++++++++++++---- 3 files changed, 135 insertions(+), 17 deletions(-) diff --git a/docs/uv.md b/docs/uv.md index b5c1c7058..2bddffb5d 100644 --- a/docs/uv.md +++ b/docs/uv.md @@ -460,6 +460,14 @@ maturin is told not to download a Rust toolchain of its own (`MATURIN_NO_INSTALL_RUST=1`): a Rust sdist with no toolchain wired fails instead of fetching rustc inside the build. +The rustc cargo runs is a wrapper that also makes the extension independent +of where the action ran: sandbox and execroot paths are remapped out of the +binaries (`--remap-path-prefix`) and target crates compile as one codegen +unit, so the wheel's bytes match across hosts and downstream actions hit the +cache. Crates that compile C or C++ through cc-rs (`ring`, `zstd-sys`) find +the wired C toolchain under `CC_`, `CXX_`, `AR_` and +`RANLIB_` instead of whatever is on the PATH. + ### Backend config settings PEP 517 backends take a free-form `config_settings` dictionary. Declare it diff --git a/uv/private/pep517_whl/tests/build_helper_test.py b/uv/private/pep517_whl/tests/build_helper_test.py index 46f3af6f9..c151316c4 100644 --- a/uv/private/pep517_whl/tests/build_helper_test.py +++ b/uv/private/pep517_whl/tests/build_helper_test.py @@ -681,7 +681,7 @@ def test_ranlib_routes_ar_s(self) -> None: # that is the host's ranlib against the target's archives. The wrapper # must route `ar s` (ranlib's POSIX spelling) through our AR. content, tmp = self._toolchain("linux", "x86_64") - ranlib = path.join(tmp, "cmake_ranlib") + ranlib = path.join(tmp, ".aspect_rules_py_compilers", "ranlib") self.assertIn('set(CMAKE_RANLIB "{}")'.format(ranlib), content) with open(ranlib) as f: wrapper = f.read() @@ -860,6 +860,73 @@ def test_no_vendor_dir_leaves_cargo_online(self) -> None: self.assertFalse(path.exists(path.join(tmp, "config.toml"))) +class RustcWrapperTest(unittest.TestCase): + """Runs the generated rustc wrapper around an argv-echoing fake rustc.""" + + def _fake_rustc(self, tmp: str) -> str: + rustc = path.join(tmp, "tc", "bin", "rustc") + makedirs(path.dirname(rustc)) + with open(rustc, "w") as f: + f.write('#!/bin/sh\nprintf \'%s\\n\' "$@"\n') + os.chmod(rustc, 0o755) + return rustc + + def test_paths_are_remapped_and_target_crates_get_one_codegen_unit(self) -> None: + tmp = tempfile.mkdtemp() + wrapper = build_helper._write_rustc_wrapper(tmp, self._fake_rustc(tmp), "/tc/sysroot", "aarch64-unknown-linux-gnu") + argv = _run_wrapper(wrapper, ["--crate-name", "ext", "--target", "aarch64-unknown-linux-gnu"]) + self.assertEqual(["--sysroot", "/tc/sysroot"], argv[:2]) + self.assertIn("--remap-path-prefix", argv) + remapped = [argv[i + 1] for i, a in enumerate(argv) if a == "--remap-path-prefix"] + self.assertIn(path.abspath(tmp) + "/=", remapped, "the sandbox root is remapped away") + self.assertIn(os.getcwd() + "=", remapped, "the execroot is remapped away") + self.assertIn("codegen-units=1", argv) + self.assertEqual(["--crate-name", "ext", "--target", "aarch64-unknown-linux-gnu"], argv[-4:], "cargo's own arguments come last, untouched") + + def test_exec_platform_crates_keep_cargo_codegen(self) -> None: + tmp = tempfile.mkdtemp() + wrapper = build_helper._write_rustc_wrapper(tmp, self._fake_rustc(tmp), "/tc/sysroot", "aarch64-unknown-linux-gnu") + argv = _run_wrapper(wrapper, ["--crate-name", "build_script_build"]) + self.assertNotIn("codegen-units=1", argv, "build scripts and proc-macros never reach the wheel") + self.assertIn("--remap-path-prefix", argv) + + def test_native_build_treats_every_crate_as_target(self) -> None: + tmp = tempfile.mkdtemp() + wrapper = build_helper._write_rustc_wrapper(tmp, self._fake_rustc(tmp), "/tc/sysroot", None) + argv = _run_wrapper(wrapper, ["--crate-name", "ext"]) + self.assertIn("codegen-units=1", argv) + + +class CcRsEnvTest(unittest.TestCase): + def test_cc_rs_finds_the_wired_toolchain_under_both_spellings(self) -> None: + tmp = tempfile.mkdtemp() + env = {"CC": "/w/cc", "CXX": "/w/c++", "AR": "/w/ar"} + build_helper._cc_rs_env(env, tmp, "aarch64-unknown-linux-gnu") + for spelling in ("aarch64-unknown-linux-gnu", "aarch64_unknown_linux_gnu"): + self.assertEqual("/w/cc", env["CC_" + spelling]) + self.assertEqual("/w/c++", env["CXX_" + spelling]) + self.assertEqual("/w/ar", env["AR_" + spelling]) + ranlib = env["RANLIB_" + spelling] + self.assertTrue(os.access(ranlib, os.X_OK)) + with open(ranlib) as f: + self.assertIn('exec "/w/ar" s "$@"', f.read()) + + def test_no_ar_no_archiver_vars(self) -> None: + env = {"CC": "/w/cc", "CXX": "/w/c++"} + build_helper._cc_rs_env(env, tempfile.mkdtemp(), "x86_64-unknown-linux-gnu") + self.assertNotIn("AR_x86_64-unknown-linux-gnu", env) + self.assertNotIn("RANLIB_x86_64_unknown_linux_gnu", env) + self.assertEqual("/w/cc", env["CC_x86_64_unknown_linux_gnu"]) + + def test_cross_env_exports_cc_rs_vars(self) -> None: + tmp = tempfile.mkdtemp() + env = {"CARGO": "/tc/bin/cargo", "RUSTC": "/tc/bin/rustc", "CC": "/w/cc", "CXX": "/w/c++", "AR": "/w/ar"} + build_helper._configure_cargo_cross_env(env, tmp, "linux", "x86_64", "musl") + self.assertEqual("/w/cc", env["CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER"]) + self.assertEqual("/w/cc", env["CC_x86_64-unknown-linux-musl"]) + self.assertEqual("/w/c++", env["CXX_x86_64_unknown_linux_musl"]) + + class CargoNativeEnvTest(unittest.TestCase): def test_rustc_gets_the_toolchain_sysroot(self) -> None: tmp = tempfile.mkdtemp() diff --git a/uv/private/pep517_whl/tools/build_helper.py b/uv/private/pep517_whl/tools/build_helper.py index 0ef977a3f..75446ea9a 100644 --- a/uv/private/pep517_whl/tools/build_helper.py +++ b/uv/private/pep517_whl/tools/build_helper.py @@ -886,11 +886,7 @@ def _generate_cmake_toolchain_file( contain spaces, which unquoted set() would parse as list separators. """ ar = build_env.get("AR", "ar") - ranlib = _write_generated_file( - path.join(tmpdir, "cmake_ranlib"), - '#!/bin/sh\nexec "{}" s "$@"\n'.format(ar), - executable=True, - ) + ranlib = _ranlib_wrapper(tmpdir, ar) return _write_generated_file( path.join(tmpdir, "cross_toolchain.cmake"), textwrap.dedent("""\ @@ -923,14 +919,68 @@ def _generate_cmake_toolchain_file( ("darwin", "libsystem"): "apple-darwin", } +# Beyond the explicit sysroot, the wrapper makes the compiled artifacts +# independent of where the action ran: rustc bakes source paths into debug +# info and panic messages, and the sandbox root and execroot differ per host +# and per action, so they are remapped away. A single codegen unit keeps +# LLVM's module ids, which leak into symbol names, stable across hosts; it +# is applied to target crates only (build scripts and proc-macros never +# reach the wheel), or to everything in a native build where cargo passes +# no --target. _RUSTC_WRAPPER = """#!/usr/bin/env python3 import os import sys -os.execv({rustc!r}, [{rustc!r}, "--sysroot", {sysroot!r}] + sys.argv[1:]) +args = sys.argv[1:] +final = [{rustc!r}, "--sysroot", {sysroot!r}] +for prefix in {remap_prefixes!r}: + final += ["--remap-path-prefix", prefix + "/=", "--remap-path-prefix", prefix + "="] +target = {target_triple!r} +if target is None or target in args: + final += ["-C", "codegen-units=1"] +os.execv(final[0], final + args) """ +def _write_rustc_wrapper(tmpdir: str, rustc: str, sysroot: str, target_triple: str | None) -> str: + """The rustc cargo runs: explicit sysroot, reproducible paths and codegen (see _RUSTC_WRAPPER).""" + return _write_generated_file( + path.join(tmpdir, ".aspect_rules_py_rustc", "rustc"), + _RUSTC_WRAPPER.format( + rustc=rustc, + sysroot=sysroot, + remap_prefixes=[path.abspath(tmpdir), os.getcwd()], + target_triple=target_triple, + ), + executable=True, + ) + + +def _ranlib_wrapper(tmpdir: str, ar: str) -> str: + """`ar s` is ranlib: a ranlib for toolchains that ship none as a separate tool.""" + return _write_generated_file( + path.join(tmpdir, ".aspect_rules_py_compilers", "ranlib"), + '#!/bin/sh\nexec "{}" s "$@"\n'.format(ar), + executable=True, + ) + + +def _cc_rs_env(build_env: dict[str, str], tmpdir: str, triple: str) -> None: + """Point cc-rs at the wired C toolchain for crates that compile C or C++. + + cc-rs (ring, zstd-sys, ...) looks up CC_, CXX_, AR_ + and RANLIB_, in the dashed and the underscored spelling, before + falling back to whatever `cc` is on PATH. The generic CC/CXX/AR only + steer host compiles. + """ + ar = build_env.get("AR", "") + tools = {"CC": build_env.get("CC", ""), "CXX": build_env.get("CXX", ""), "AR": ar, "RANLIB": _ranlib_wrapper(tmpdir, ar) if ar else ""} + for spelling in (triple, triple.replace("-", "_")): + for tool, value in tools.items(): + if value: + build_env["{}_{}".format(tool, spelling)] = value + + def _merge_rust_sysroot(tmpdir: str, target_rustc: str, host_sysroot: str, target_sysroot: str | None = None) -> str: """Symlink-merge the target toolchain's sysroot with the host's rust-std. @@ -1002,6 +1052,7 @@ def _configure_cargo_cross_env(build_env: dict[str, str], tmpdir: str, target_os build_env["CARGO_BUILD_TARGET"] = triple linker_var = "CARGO_TARGET_{}_LINKER".format(triple.upper().replace("-", "_")) build_env[linker_var] = build_env["CC"] + _cc_rs_env(build_env, tmpdir, triple) # pyo3-ffi refuses to cross-compile without an explicit target Python # version. Unused (harmless) for non-PyO3 crates. @@ -1010,11 +1061,7 @@ def _configure_cargo_cross_env(build_env: dict[str, str], tmpdir: str, target_os host_sysroot = build_env.get("RULES_PY_RUST_HOST_SYSROOT") if host_sysroot: merged_sysroot = _merge_rust_sysroot(tmpdir, build_env["RUSTC"], host_sysroot, build_env.get("RULES_PY_RUST_SYSROOT")) - build_env["RUSTC"] = _write_generated_file( - path.join(tmpdir, ".aspect_rules_py_rustc", "rustc"), - _RUSTC_WRAPPER.format(rustc=build_env["RUSTC"], sysroot=merged_sysroot), - executable=True, - ) + build_env["RUSTC"] = _write_rustc_wrapper(tmpdir, build_env["RUSTC"], merged_sysroot, triple) # In cross mode maturin name-parses its -i interpreter argument for a # "pythonX.Y"-shaped basename instead of executing it; our venv's @@ -1086,11 +1133,7 @@ def _configure_cargo_native_env(build_env: dict[str, str], tmpdir: str) -> None: sysroot = build_env.get("RULES_PY_RUST_SYSROOT") or build_env.get("RULES_PY_RUST_HOST_SYSROOT") if not (build_env.get("CARGO") and sysroot): return - build_env["RUSTC"] = _write_generated_file( - path.join(tmpdir, ".aspect_rules_py_rustc", "rustc"), - _RUSTC_WRAPPER.format(rustc=build_env["RUSTC"], sysroot=sysroot), - executable=True, - ) + build_env["RUSTC"] = _write_rustc_wrapper(tmpdir, build_env["RUSTC"], sysroot, None) def _build_backend(pyproject_data: dict[str, object] | None) -> str | None: From 6776dd89f671faed699af84466e10e6237ebfecf Mon Sep 17 00:00:00 2001 From: Cesar Abel Date: Thu, 10 Sep 2026 16:22:44 -0600 Subject: [PATCH 2/2] fix(uv): turn off maturin's SBOM so Rust wheels are reproducible The CycloneDX SBOM maturin writes into the wheel records every crate as path+file:////..., and the sandbox id differs per action, so two builds of one sdist never produced the same wheel even with the rustc wrapper remapping paths. maturin 1.15 has no flag or variable for it, only the [tool.maturin.sbom] table, appended to the extracted pyproject.toml unless the sdist configures it itself. --- docs/uv.md | 5 ++-- .../pep517_whl/tests/build_helper_test.py | 27 +++++++++++++++++++ uv/private/pep517_whl/tools/build_helper.py | 26 ++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/docs/uv.md b/docs/uv.md index 2bddffb5d..bcd5b2684 100644 --- a/docs/uv.md +++ b/docs/uv.md @@ -463,8 +463,9 @@ instead of fetching rustc inside the build. The rustc cargo runs is a wrapper that also makes the extension independent of where the action ran: sandbox and execroot paths are remapped out of the binaries (`--remap-path-prefix`) and target crates compile as one codegen -unit, so the wheel's bytes match across hosts and downstream actions hit the -cache. Crates that compile C or C++ through cc-rs (`ring`, `zstd-sys`) find +unit, and maturin's SBOM, which records sandbox paths, is turned off unless +the sdist configures it; the wheel's bytes then match across hosts and +downstream actions hit the cache. Crates that compile C or C++ through cc-rs (`ring`, `zstd-sys`) find the wired C toolchain under `CC_`, `CXX_`, `AR_` and `RANLIB_` instead of whatever is on the PATH. diff --git a/uv/private/pep517_whl/tests/build_helper_test.py b/uv/private/pep517_whl/tests/build_helper_test.py index c151316c4..a02e03e0b 100644 --- a/uv/private/pep517_whl/tests/build_helper_test.py +++ b/uv/private/pep517_whl/tests/build_helper_test.py @@ -897,6 +897,33 @@ def test_native_build_treats_every_crate_as_target(self) -> None: self.assertIn("codegen-units=1", argv) +class DisableMaturinSbomTest(unittest.TestCase): + def _worktree(self, pyproject: str | None) -> str: + tmp = tempfile.mkdtemp() + if pyproject is not None: + with open(path.join(tmp, "pyproject.toml"), "w") as f: + f.write(pyproject) + return tmp + + def test_sbom_is_turned_off(self) -> None: + tmp = self._worktree('[build-system]\nbuild-backend = "maturin"\n') + self.assertTrue(build_helper._disable_maturin_sbom(tmp)) + with open(path.join(tmp, "pyproject.toml")) as f: + content = f.read() + self.assertIn("[tool.maturin.sbom]\nrust = false\nauditwheel = false\n", content) + self.assertTrue(content.startswith("[build-system]"), "the sdist's own configuration is kept") + + def test_explicit_sbom_configuration_is_respected(self) -> None: + original = '[tool.maturin.sbom]\nrust = true\n' + tmp = self._worktree(original) + self.assertFalse(build_helper._disable_maturin_sbom(tmp)) + with open(path.join(tmp, "pyproject.toml")) as f: + self.assertEqual(original, f.read()) + + def test_no_pyproject_no_change(self) -> None: + self.assertFalse(build_helper._disable_maturin_sbom(self._worktree(None))) + + class CcRsEnvTest(unittest.TestCase): def test_cc_rs_finds_the_wired_toolchain_under_both_spellings(self) -> None: tmp = tempfile.mkdtemp() diff --git a/uv/private/pep517_whl/tools/build_helper.py b/uv/private/pep517_whl/tools/build_helper.py index 75446ea9a..77a489faa 100644 --- a/uv/private/pep517_whl/tools/build_helper.py +++ b/uv/private/pep517_whl/tools/build_helper.py @@ -1074,6 +1074,30 @@ def _configure_cargo_cross_env(build_env: dict[str, str], tmpdir: str, target_os build_env["MATURIN_PEP517_ARGS"] = (interpreter_arg + " " + existing).strip() +_MATURIN_SBOM_OFF = "\n[tool.maturin.sbom]\nrust = false\nauditwheel = false\n" + + +def _disable_maturin_sbom(worktree: str) -> bool: + """Turn off maturin's CycloneDX SBOM unless the sdist configures it itself. + + The SBOM records every crate as `path+file:////...`: the sandbox + id differs per action, so two builds of one sdist never produce the same + wheel while it is on. maturin 1.15 offers no flag or variable for it, only + the `[tool.maturin.sbom]` table, so it is appended to the extracted + pyproject.toml. Returns whether the file was changed. + """ + pyproject = path.join(worktree, "pyproject.toml") + if not path.exists(pyproject): + return False + with open(pyproject, encoding="utf-8") as f: + content = f.read() + if "[tool.maturin.sbom]" in content: + return False + with open(pyproject, "a", encoding="utf-8") as f: + f.write(_MATURIN_SBOM_OFF) + return True + + def _inject_cargo_lock(worktree: str, lock_path: str) -> str | None: """Copy a user-supplied Cargo.lock next to the source tree's top-level Cargo.toml. @@ -1327,6 +1351,8 @@ def main() -> None: _forbid_backend_toolchain_downloads(build_env) _configure_cargo_offline(build_env, opts.cargo_vendor_dir) _inject_cargo_lock(t, opts.cargo_lock) + if _build_backend(_load_pyproject_data(t)) == "maturin": + _disable_maturin_sbom(t) if _legacy_metadata_conflicts_with_pyproject(t): print(