diff --git a/docs/uv.md b/docs/uv.md index b5c1c7058..bcd5b2684 100644 --- a/docs/uv.md +++ b/docs/uv.md @@ -460,6 +460,15 @@ 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, 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. + ### 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..a02e03e0b 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,100 @@ 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 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() + 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..77a489faa 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 @@ -1027,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. @@ -1086,11 +1157,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: @@ -1284,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(