diff --git a/.github/actions/setup-dev-fast/action.yml b/.github/actions/setup-dev-fast/action.yml new file mode 100644 index 000000000..1287abde0 --- /dev/null +++ b/.github/actions/setup-dev-fast/action.yml @@ -0,0 +1,31 @@ +--- +name: Set up Linux dev-fast prerequisites +description: Install the pinned Cranelift component and checksum-verified mold binary. +runs: + using: composite + steps: + - name: Install the pinned backend and linker + shell: bash + run: | + set -euo pipefail + toolchain='nightly-2026-08-23' + component='rustc-codegen-cranelift' + version="$(tr -d '\r\n' < tools/mold/VERSION)" + case "$(uname -m)" in + x86_64|amd64) architecture=x86_64 ;; + aarch64|arm64) architecture=aarch64 ;; + *) echo "unsupported mold architecture: $(uname -m)" >&2; exit 1 ;; + esac + archive="mold-${version}-${architecture}-linux.tar.gz" + expected="$(awk -v archive="${archive}" '$2 == archive { print $1 }' tools/mold/SHA256SUMS)" + test "$(printf '%s\n' "${expected}" | sed '/^$/d' | wc -l)" -eq 1 + rustup toolchain install "${toolchain}" --profile minimal + rustup component add "${component}" clippy --toolchain "${toolchain}" + curl --fail --location --silent --show-error \ + --output "${RUNNER_TEMP}/${archive}" \ + "https://github.com/rui314/mold/releases/download/v${version}/${archive}" + printf '%s %s\n' "${expected}" "${RUNNER_TEMP}/${archive}" | sha256sum --check --status + mkdir --parents "${HOME}/.local" + tar --extract --gzip --strip-components=1 --directory "${HOME}/.local" \ + --file "${RUNNER_TEMP}/${archive}" + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7548cbc87..729777321 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,6 +254,11 @@ jobs: cache-provider: external use-sccache: 'false' + # Only the Linux debug route selects Cranelift and mold. The stable + # toolchain remains selected for Whitaker and every other lane. + - name: Install Linux dev-fast prerequisites + uses: ./.github/actions/setup-dev-fast + - name: Install Whitaker uses: leynos/shared-actions/.github/actions/install-whitaker@c5a54701c8603a0fa756a6b34c49bc2af75a6c11 with: @@ -290,6 +295,11 @@ jobs: - name: Run lint, including Skylos dead-code detection run: /usr/bin/make ACTIONLINT="$GITHUB_WORKSPACE/actionlint" lint + # The accelerated lint pass uses a maintenance nightly, so retain a + # separate fragment-free compilation check on the published Rust MSRV. + - name: Verify Rust MSRV + run: make msrv-check + # The Windows wheel build compiles this arm but without `-D warnings`, # so it catches a Windows arm that fails to build and nothing else. This # step is what holds the `#[cfg(windows)]` and `#[cfg(unix)]` branches to @@ -716,6 +726,9 @@ jobs: use-sccache: 'false' install-binstall: 'false' + - name: Install Linux dev-fast prerequisites + uses: ./.github/actions/setup-dev-fast + - name: Prepare Cargo dependencies run: cargo fetch --locked --manifest-path rust/cuprum-rust/Cargo.toml diff --git a/AGENTS.md b/AGENTS.md index ad61e00d1..f9fb3ac23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,8 +226,15 @@ working on the Rust portions of the project: - The project toolchain stays pinned to Rust `1.85.0` and declares `rustfmt`, `clippy`, and `rust-analyzer`. CI also provisions `nightly-2026-05-28` for the maintenance formatter, then restores the project toolchain. The current - `fmt` and `check-fmt` recipes still use the project toolchain; the nightly is - not a build, test, lint, or documentation toolchain. + `fmt` and `check-fmt` recipes still use the project toolchain. Linux-only + debug Rust work additionally uses the separately pinned dev-fast nightly + through `tools/dev-fast/config.toml`; run `make dev-fast-check` first, since + it requires both the `rustc-codegen-cranelift` and `clippy` components. The + stable toolchain remains mandatory for release, coverage, Whitaker, the + explicit `make msrv-check` verification target, and the macOS and Windows + platform alternatives. All Rust packages inherit Cargo's + `rust-version = "1.85.0"` contract, which also keeps MSRV-aware Clippy + findings valid when debug linting uses the dev-fast nightly. - Clippy warnings MUST be disallowed. - Fix any warnings emitted during tests in code instead of silencing them. - Where a function is too long, extract meaningfully named helper functions diff --git a/Makefile b/Makefile index 11bf6082d..81bae9fc0 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,46 @@ TOOLS = $(MDLINT) $(YAMLLINT) $(ACTIONLINT) uv VENV_TOOLS = pytest ruff RUST_DIR ?= rust CARGO ?= cargo +# Rust 1.85.0 remains Cuprum's published compatibility toolchain. This +# separately pinned nightly is selected only for compatible Linux debug work. +MSRV_TOOLCHAIN ?= 1.85.0 +DEV_FAST_TOOLCHAIN ?= nightly-2026-08-23 +DEV_FAST_CRANELIFT_COMPONENT ?= rustc-codegen-cranelift +# Clippy is a separate rustup component from the backend, but `rust-lint` runs +# it through this same nightly, so the prerequisite gate must require both. +DEV_FAST_LINT_COMPONENT ?= clippy +DEV_FAST_REQUIRED_COMPONENTS ?= $(DEV_FAST_CRANELIFT_COMPONENT) $(DEV_FAST_LINT_COMPONENT) +# The selected Cargo fragment is repository-fixed state, not a caller input. +# `override` is load-bearing, not decoration: a plain or `?=` definition still +# yields to an ordinary environment variable, and any non-override definition +# yields to a command-line variable. With either, `make dev-build +# DEV_FAST_RUST_CONFIG=/tmp/other.toml` would run the accelerated route under a +# configuration the project never approved while `dev-fast-check` still passed. +# Both names need it: the second because a caller could otherwise replace the +# derived value directly. Anything needing a different Cargo configuration is +# outside this route and does not use it. +override DEV_FAST_CONFIG_RELATIVE := tools/dev-fast/config.toml +override DEV_FAST_RUST_CONFIG := ../$(DEV_FAST_CONFIG_RELATIVE) +DEV_FAST_MOLD_VERSION_FILE ?= tools/mold/VERSION +DEV_FAST_MOLD_VERSION := $(strip $(shell tr -d '\r\n' < $(DEV_FAST_MOLD_VERSION_FILE))) +DEV_FAST_MOLD_VERSION_PATTERN = $(subst .,\.,$(DEV_FAST_MOLD_VERSION)) +DEV_FAST_CARGO_BRIDGE ?= tools/dev-fast/cargo +DEV_FAST_HOST_IS_LINUX := $(if $(filter Linux,$(shell uname -s)),yes) +# `CARGO` remains a caller-injectable single executable. Maturin can select +# that executable but cannot pass Cargo's configuration-file `--config` form. +DEV_FAST_CARGO_COMMAND = RUSTUP_TOOLCHAIN=$(DEV_FAST_TOOLCHAIN) $(CARGO) --config $(DEV_FAST_RUST_CONFIG) +MSRV_CARGO_COMMAND = RUSTUP_TOOLCHAIN=$(MSRV_TOOLCHAIN) $(CARGO) +RUST_DEBUG_CARGO = $(if $(DEV_FAST_HOST_IS_LINUX),$(DEV_FAST_CARGO_COMMAND),$(CARGO)) +RUST_DEBUG_PREREQUISITE = $(if $(DEV_FAST_HOST_IS_LINUX),dev-fast-check) +# Parse release spellings without re-parsing caller-provided flags in a shell. +maturin_release_profile = $(strip $(if $(1),$(if $(and $(filter --profile,$(firstword $(1))),$(filter release,$(word 2,$(1)))),yes,$(call maturin_release_profile,$(wordlist 2,$(words $(1)),$(1)))))) +MATURIN_DEVELOP_IS_RELEASE := $(strip $(filter --release -r --profile=release,$(MATURIN_DEVELOP_FLAGS)) $(call maturin_release_profile,$(MATURIN_DEVELOP_FLAGS))) +DEVELOP_DEV_FAST_ENABLED := $(if $(DEV_FAST_HOST_IS_LINUX),$(if $(MATURIN_DEVELOP_IS_RELEASE),,yes)) +DEVELOP_DEV_FAST_PREREQUISITE = $(if $(DEVELOP_DEV_FAST_ENABLED),dev-fast-check) +DEVELOP_DEV_FAST_ENV = $(if $(DEVELOP_DEV_FAST_ENABLED),RUSTUP_TOOLCHAIN=$(DEV_FAST_TOOLCHAIN) DEV_FAST_CARGO=$(CARGO) CARGO=$(DEV_FAST_CARGO_BRIDGE)) +DEV_FAST_CHECK_COMMAND = test "$(DEV_FAST_HOST_IS_LINUX)" = yes || { printf '%s\n' 'dev-fast is supported only on Linux; use the stable backend on this host' >&2; exit 1; }; test -f "$(DEV_FAST_CONFIG_RELATIVE)" || { printf 'dev-fast configuration is missing: %s\n' "$(DEV_FAST_CONFIG_RELATIVE)" >&2; exit 1; }; command -v mold >/dev/null 2>&1 || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; mold --version | grep -q '^mold $(DEV_FAST_MOLD_VERSION_PATTERN)\($$\|[[:space:]]\)' || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; components="$$(rustup component list --installed --toolchain "$(DEV_FAST_TOOLCHAIN)")" || { printf 'cannot inspect the components installed for %s\n' "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; }; for component in $(DEV_FAST_REQUIRED_COMPONENTS); do printf '%s\n' "$$components" | grep -q "^$$component" || { printf 'install %s for %s before using dev-fast\n' "$$component" "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; }; done +DEV_FAST_TEST_RUSTFLAGS = $(TEST_RUSTFLAGS) $(if $(DEV_FAST_HOST_IS_LINUX),-Clink-arg=-fuse-ld=mold) +DEV_FAST_TEST_COMMAND = if $(LOCAL_TOOL_ENV) command -v cargo-nextest >/dev/null 2>&1; then cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) nextest run $(TEST_FLAGS) $(BUILD_JOBS); else echo "cargo-nextest not found; falling back to cargo test." >&2; cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) test $(TEST_FLAGS) $(BUILD_JOBS); fi WHITAKER ?= whitaker BUILD_JOBS ?= RUST_FLAGS ?= -D warnings @@ -160,6 +200,7 @@ MDLINT_CHECK_COMMAND = unset FORCE_COLOR; $(LOCAL_TOOL_ENV) xargs -0 -r $(MDLINT lint-windows fmt check-fmt \ markdownlint spelling nixie test test-python test-rust typecheck \ test-extension test-markdown-format develop makeutil skylos-allow \ + test-dev-fast-contract dev-fast-check dev-build dev-test msrv-check \ benchmark-micro benchmark-e2e \ $(TOOLS) $(VENV_TOOLS) .NOTPARALLEL: lint @@ -176,9 +217,9 @@ build: uv .venv ## Build virtual-env and install deps # Why this exists and why `ensurepip` comes first: see "Building the # extension for tests" in docs/developers-guide.md. -develop: build ## Build the native extension into the dev virtual-env +develop: build $(DEVELOP_DEV_FAST_PREREQUISITE) ## Build the native extension into the dev virtual-env $(UV_RUN_ENV) uv run $(UV_RUN_FLAGS) python -m ensurepip --upgrade - $(UV_RUN_ENV) uv run $(UV_RUN_FLAGS) maturin develop $(MATURIN_DEVELOP_FLAGS) --manifest-path $(RUST_DIR)/cuprum-rust/Cargo.toml + $(DEVELOP_DEV_FAST_ENV) $(UV_RUN_ENV) uv run $(UV_RUN_FLAGS) maturin develop $(MATURIN_DEVELOP_FLAGS) --manifest-path $(RUST_DIR)/cuprum-rust/Cargo.toml build-release: ## Build artefacts (sdist & wheel) python -m build --sdist --wheel @@ -254,8 +295,8 @@ python-lint: ruff uv ## Run Ruff, interrogate, pylint, df12-python-lints, and am $(AMBRLEAKS) cuprum/unittests scripts/tests tests $(SKYLOS) $(SKYLOS_PRODUCTION_TARGETS) --exclude $(SKYLOS_EXCLUDE_FOLDERS) --category dead_code --gate --format concise --no-upload --no-provenance --no-grep-verify -rust-lint: ## Run Rust documentation, Clippy, Whitaker, and spelling checks - cd $(RUST_DIR) && RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) doc --no-deps $(DOC_FLAGS) && $(CARGO) clippy $(CLIPPY_FLAGS) +rust-lint: $(RUST_DEBUG_PREREQUISITE) ## Run Rust documentation, Clippy, Whitaker, and spelling checks + cd $(RUST_DIR) && RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(RUST_DEBUG_CARGO) doc --no-deps $(DOC_FLAGS) && $(RUST_DEBUG_CARGO) clippy $(CLIPPY_FLAGS) @if ! $(LOCAL_TOOL_ENV) command -v $(WHITAKER) >/dev/null 2>&1; then echo "whitaker is required for linting. Install it before running this target." >&2; exit 1; fi cd $(RUST_DIR) && $(LOCAL_TOOL_ENV) RUSTFLAGS="$(WHITAKER_RUSTFLAGS)" $(WHITAKER) --all -- $(WHITAKER_CARGO_FLAGS) +$(MAKE) spelling @@ -311,19 +352,34 @@ test-python: build uv $(VENV_TOOLS) makeutil ## Run the Python suite CARGO_BUILD_JOBS="$(PYTEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(PYTEST_RUSTFLAGS)" $(PYTEST) -v -n $(PYTEST_WORKERS) "$$@" || exit $$?; \ done -test-rust: ## Run the Rust suite +test-rust: $(RUST_DEBUG_PREREQUISITE) ## Run the Rust suite @if $(LOCAL_TOOL_ENV) command -v cargo-nextest >/dev/null 2>&1; then \ - cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(CARGO) nextest run $(TEST_FLAGS) $(BUILD_JOBS); \ + cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(RUST_DEBUG_CARGO) nextest run $(TEST_FLAGS) $(BUILD_JOBS); \ else \ echo "cargo-nextest not found; falling back to cargo test." >&2; \ - cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(CARGO) test $(TEST_FLAGS) $(BUILD_JOBS); \ + cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(RUST_DEBUG_CARGO) test $(TEST_FLAGS) $(BUILD_JOBS); \ fi +msrv-check: ## Verify every Rust target compiles on the published MSRV + cd $(RUST_DIR) && $(MSRV_CARGO_COMMAND) check --workspace --all-targets --all-features + +dev-fast-check: ## Verify Linux dev-fast prerequisites + @$(DEV_FAST_CHECK_COMMAND) + +dev-build: dev-fast-check ## Build Rust debug targets through the accelerated route + cd $(RUST_DIR) && $(DEV_FAST_CARGO_COMMAND) build $(CARGO_FLAGS) + +dev-test: dev-fast-check ## Test Rust through the accelerated route + @$(DEV_FAST_TEST_COMMAND) + # Run `make develop` first. Without the extension the guard fails the run with # a message naming that command, which is the intended diagnostic. test-extension: build uv $(VENV_TOOLS) ## Run the extension-gated tests, requiring the extension CUPRUM_REQUIRE_RUST_EXTENSION=1 $(PYTEST) -v $(EXTENSION_TEST_TARGETS) +test-dev-fast-contract: build uv $(VENV_TOOLS) ## Validate dev-fast routing and adapter contracts + $(PYTEST) -v cuprum/unittests/test_dev_fast_contract.py cuprum/unittests/test_dev_fast_adapter.py cuprum/unittests/test_dev_fast_prerequisites.py tests/test_dev_fast_action.py + benchmark-micro: build uv ## Run pytest-benchmark microbenchmarks mkdir -p dist/benchmarks $(UV_RUN_ENV) CUPRUM_RUN_BENCHMARKS=1 uv run pytest -q \ diff --git a/cuprum/unittests/test_dev_fast_adapter.py b/cuprum/unittests/test_dev_fast_adapter.py new file mode 100644 index 000000000..dbcd8df53 --- /dev/null +++ b/cuprum/unittests/test_dev_fast_adapter.py @@ -0,0 +1,208 @@ +"""Validate the Maturin Cargo adapter's process and configuration boundary.""" + +from __future__ import annotations + +import os +import subprocess # ruff: ignore[suspicious-subprocess-import] - controlled adapter argv. +import tempfile +from pathlib import Path + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from tests.helpers.docs import repo_root + +ADAPTER = "tools/dev-fast/cargo" +FRAGMENT = "tools/dev-fast/config.toml" +SAFE_CARGO_ARGUMENTS = st.sampled_from(( + "rustc", + "--lib", + "--package", + "cuprum-streams", + "--quiet", +)) + + +def _adapter_fragment() -> str: + """Return the fragment path the adapter resolves from its own location.""" + return str((repo_root() / FRAGMENT).resolve()) + + +def _bridge_environment(tmp_path: Path) -> tuple[dict[str, str], Path, Path]: + """Create a recording Cargo child for the adapter's process contract.""" + argv_path = tmp_path / "argv" + cargo_path = tmp_path / "cargo" + child = tmp_path / "cargo-child" + child.write_text( + "#!/usr/bin/env bash\n" + 'printf \'%s\\n\' "$@" > "$DEV_FAST_ARGV"\n' + 'printf \'%s\\n\' "$CARGO" > "$DEV_FAST_CARGO_ENV"\n' + "exit 47\n", + encoding="utf-8", + ) + child.chmod(0o755) + # DEV_FAST_CONFIG is deliberately absent: the adapter derives the fragment + # from its own location, so the variable is not part of its interface. + return ( + { + "DEV_FAST_ARGV": str(argv_path), + "DEV_FAST_CARGO": str(child), + "DEV_FAST_CARGO_ENV": str(cargo_path), + }, + argv_path, + cargo_path, + ) + + +def _run_bridge( + arguments: list[str], + environment: dict[str, str], + *, + path: str = f"./{ADAPTER}", +) -> subprocess.CompletedProcess[str]: + """Run the controlled adapter process with captured diagnostics. + + ``path`` defaults to the relative spelling because that is how the + Makefile actually invokes the adapter; pass an absolute path to cover the + other form. The adapter reads its own ``$0``, so neither spelling may + change which fragment it selects. + + Returns + ------- + The completed adapter process, with captured output and exit status. + """ + return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed adapter argv. + [path, *arguments], + check=False, + capture_output=True, + cwd=repo_root(), + env={**os.environ, **environment}, + text=True, + ) + + +@pytest.mark.parametrize("invocation", ["relative", "absolute"]) +def test_bridge_resolves_its_fragment_from_either_invocation_form( + tmp_path: Path, invocation: str +) -> None: + """The adapter finds the repository fragment however it is invoked.""" + environment, argv_path, _ = _bridge_environment(tmp_path) + path = f"./{ADAPTER}" if invocation == "relative" else str(repo_root() / ADAPTER) + result = _run_bridge(["rustc", "--lib"], environment, path=path) + assert result.returncode == 47, f"the {invocation} invocation must reach Cargo" + assert argv_path.read_text(encoding="utf-8").splitlines() == [ + "--config", + _adapter_fragment(), + "rustc", + "--lib", + ], f"the {invocation} invocation must select the repository fragment" + + +def test_bridge_injects_one_fragment_and_preserves_child_exit(tmp_path: Path) -> None: + """The Maturin adapter preserves Cargo's argv, environment, and status.""" + environment, argv_path, cargo_path = _bridge_environment(tmp_path) + result = _run_bridge(["rustc", "--lib"], environment) + assert result.returncode == 47, "the adapter must preserve the Cargo exit status" + assert argv_path.read_text(encoding="utf-8").splitlines() == [ + "--config", + _adapter_fragment(), + "rustc", + "--lib", + ], "the adapter must prepend only its approved fragment" + assert ( + cargo_path.read_text(encoding="utf-8").strip() == environment["DEV_FAST_CARGO"] + ), "Cargo children must receive the real executable, not the adapter" + + +@settings(max_examples=24, deadline=None) +@given(st.lists(SAFE_CARGO_ARGUMENTS, min_size=1, max_size=7)) +def test_bridge_preserves_generated_cargo_arguments(arguments: list[str]) -> None: + """The adapter preserves each safe generated Cargo argv sequence exactly.""" + with tempfile.TemporaryDirectory() as directory: + environment, argv_path, _ = _bridge_environment(Path(directory)) + result = _run_bridge(arguments, environment) + assert result.returncode == 47, f"the child status changed for {arguments!r}" + assert argv_path.read_text(encoding="utf-8").splitlines() == [ + "--config", + _adapter_fragment(), + *arguments, + ], f"the adapter changed generated Cargo arguments {arguments!r}" + + +@pytest.mark.parametrize("config_flag", ["--config", "--config=other.toml"]) +def test_bridge_rejects_duplicate_configuration( + tmp_path: Path, config_flag: str +) -> None: + """A caller cannot override or duplicate the approved configuration.""" + environment, argv_path, _ = _bridge_environment(tmp_path) + command = ["rustc", config_flag] + if config_flag == "--config": + command.append("other.toml") + result = _run_bridge(command, environment) + assert result.returncode == 2, "a duplicate configuration must be rejected" + assert "Cargo configuration is selected" in result.stderr, ( + "the duplicate configuration diagnostic must explain the owning boundary" + ) + assert not argv_path.exists(), "the adapter must reject before starting Cargo" + + +@settings(max_examples=24, deadline=None) +@given( + st.lists(SAFE_CARGO_ARGUMENTS, max_size=4), + st.sampled_from(("--config", "--config=other.toml")), + st.lists(SAFE_CARGO_ARGUMENTS, max_size=4), +) +def test_bridge_rejects_generated_configuration_flags( + prefix: list[str], config_flag: str, suffix: list[str] +) -> None: + """Every generated configuration spelling is rejected before Cargo starts.""" + with tempfile.TemporaryDirectory() as directory: + environment, argv_path, _ = _bridge_environment(Path(directory)) + arguments = [ + *prefix, + config_flag, + *(["other.toml"] if config_flag == "--config" else []), + *suffix, + ] + result = _run_bridge(arguments, environment) + assert result.returncode == 2, ( + f"configuration flag escaped rejection: {arguments!r}" + ) + assert not argv_path.exists(), "a rejected invocation must not start Cargo" + + +def test_bridge_rejects_missing_required_environment(tmp_path: Path) -> None: + """The adapter refuses incomplete configuration before executing Cargo.""" + environment, argv_path, _ = _bridge_environment(tmp_path) + del environment["DEV_FAST_CARGO"] + result = _run_bridge(["rustc"], environment) + assert result.returncode == 2, "missing DEV_FAST_CARGO must fail with usage status" + assert "DEV_FAST_CARGO must name" in result.stderr, ( + "missing DEV_FAST_CARGO must name the required environment variable" + ) + assert not argv_path.exists(), "incomplete adapter state must not start Cargo" + + +def test_bridge_ignores_a_caller_supplied_configuration(tmp_path: Path) -> None: + """The adapter's fragment follows from its location, not the environment. + + Both candidates are offered, because they fail differently: an existing + file outside the repository defeats a bare `-f` existence guard, and a + path inside the repository defeats an attempt to shadow the fragment + without leaving the checkout. + """ + outside = tmp_path / "other-config.toml" + outside.write_text("[profile.dev]\n", encoding="utf-8") + for redirect in (outside, repo_root() / "other-config.toml"): + environment, argv_path, _ = _bridge_environment(tmp_path) + environment["DEV_FAST_CONFIG"] = str(redirect) + result = _run_bridge(["rustc"], environment) + assert result.returncode == 47, ( + f"a caller-supplied DEV_FAST_CONFIG must not change behaviour: {redirect}" + ) + assert argv_path.read_text(encoding="utf-8").splitlines() == [ + "--config", + _adapter_fragment(), + "rustc", + ], f"the adapter must ignore {redirect} and select its own fragment" diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py new file mode 100644 index 000000000..7404b082f --- /dev/null +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -0,0 +1,338 @@ +"""Hold the Linux-only Cargo acceleration route to its explicit contract.""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] - controlled test argv. +import tomllib +import typing as typ + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from tests.helpers.docs import repo_root + +if typ.TYPE_CHECKING: + from collections import abc as cabc + + +FRAGMENT_SHA256 = "8619efda5ea1c3232f413ae96ff56869ab6b2b7cd5bdef5a001ad16ebeac23a5" +FRAGMENT = "tools/dev-fast/config.toml" +RUST_MEMBERS = ("cuprum-rust", "cuprum-streams", "cuprum-native-io") +SAFE_MATURIN_FLAGS = st.sampled_from(( + "--release", + "-r", + "--profile", + "release", + "--profile=release", + "--skip-install", +)) + + +def _dry_run(*goals: str, variables: dict[str, str] | None = None) -> str: + """Return evaluated Make recipes for a controlled caller environment.""" + make = shutil.which("make") + assert make is not None, "the routing contract requires GNU Make on PATH" + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [ + make, + "--dry-run", + "CARGO=probe-cargo", + *(f"{key}={value}" for key, value in (variables or {}).items()), + *goals, + ], + capture_output=True, + check=True, + cwd=repo_root(), + env={**os.environ, "MAKEFLAGS": ""}, + text=True, + ) + return completed.stdout + + +def _routing_lines(output: str) -> list[str]: + """Return evaluated Cargo lines, excluding spelling's unrelated config.""" + return [ + line + for line in output.splitlines() + if "probe-cargo" in line or "CARGO=tools/dev-fast/cargo" in line + ] + + +def _assert_fragment_free(route: str) -> None: + """Reject any selected Linux development acceleration from a protected route.""" + assert "--config" not in route, f"protected route selected Cargo config: {route}" + assert "tools/dev-fast/cargo" not in route, ( + f"protected route selected bridge: {route}" + ) + assert "nightly-2026-08-23" not in route, ( + f"protected route selected nightly: {route}" + ) + assert "DEV_FAST_" not in route, f"protected route leaked dev-fast state: {route}" + + +def _assert_fragment_mutation_is_rejected(route: str) -> None: + """Prove fragment-free assertions fail when a protected route is contaminated.""" + mutation = f"{route}\nprobe-cargo --config {FRAGMENT} check" + with pytest.raises(AssertionError): + _assert_fragment_free(mutation) + + +def _is_release_maturin_flags(flags: list[str]) -> bool: + """Model the documented Makefile spellings that must select a release build.""" + return any( + flag in {"--release", "-r", "--profile=release"} + or (flag == "--profile" and flags[index + 1 : index + 2] == ["release"]) + for index, flag in enumerate(flags) + ) + + +def test_make_reads_the_linker_version_from_its_pin() -> None: + """Keep prerequisite diagnostics coupled to the checked-in linker pin.""" + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + assert "DEV_FAST_MOLD_VERSION_FILE ?= tools/mold/VERSION" in makefile, ( + "the prerequisite check must read the tracked linker version file" + ) + assert "mold 2.41.0 is required" not in makefile, ( + "Make diagnostics must not retain a stale hard-coded linker version" + ) + + +def test_the_prerequisite_gate_requires_both_nightly_components() -> None: + """Clippy runs through the dev-fast nightly, so the gate must require it.""" + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + assert "DEV_FAST_LINT_COMPONENT ?= clippy" in makefile, ( + "the dev-fast lint route depends on a separately installed Clippy" + ) + assert ( + "DEV_FAST_REQUIRED_COMPONENTS ?= $(DEV_FAST_CRANELIFT_COMPONENT) " + "$(DEV_FAST_LINT_COMPONENT)" in makefile + ), "the prerequisite gate must enumerate every component it requires" + assert "$(DEV_FAST_REQUIRED_COMPONENTS)" in makefile, ( + "the prerequisite gate must check every required component" + ) + + +def test_fragment_is_the_approved_immutable_extension() -> None: + """Keep the checked-in Cargo fragment byte-identical to its approved pin.""" + fragment = (repo_root() / FRAGMENT).read_bytes() + assert hashlib.sha256(fragment).hexdigest() == FRAGMENT_SHA256, ( + "the reviewed fragment digest must change with its contents" + ) + developers_guide = (repo_root() / "docs/developers-guide.md").read_text( + encoding="utf-8" + ) + assert FRAGMENT_SHA256 in developers_guide, ( + "the developer guide must publish the same reviewed fragment digest" + ) + + +def test_the_routed_fragment_path_cannot_be_overridden() -> None: + """The selected fragment is Makefile-fixed, not a caller-supplied variable. + + A `?=` or plain assignment here would let an environment variable or a + command-line variable select an unreviewed Cargo configuration while every + routing test still passed, because those tests exercise the default + invocation. GNU Make's `override` directive is the only definition form that + outranks both, so it is required rather than stylistic. + """ + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + assert "override DEV_FAST_CONFIG_RELATIVE := tools/dev-fast/config.toml" in ( + makefile + ), "the fragment path must be a Makefile-owned constant no caller can replace" + assert "DEV_FAST_RUST_CONFIG ?=" not in makefile, ( + "a `?=` definition still yields to an ordinary environment variable" + ) + assert "\nDEV_FAST_CONFIG ?=" not in makefile, ( + "the fragment path must not remain caller-overridable" + ) + + +def test_the_adapter_environment_does_not_expose_the_configuration() -> None: + """The Maturin adapter derives its fragment, so nothing passes it one.""" + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + assert "DEV_FAST_CONFIG=" not in makefile, ( + "the routed adapter environment must not carry a caller-settable fragment" + ) + output = _dry_run("develop", variables={"DEV_FAST_HOST_IS_LINUX": "yes"}) + assert "DEV_FAST_CONFIG=" not in output, ( + "develop must not hand the adapter a configuration path to trust" + ) + + +def test_every_workspace_member_inherits_the_published_msrv() -> None: + """Keep Clippy's MSRV-aware diagnostics aligned with Cargo metadata.""" + rust_root = repo_root() / "rust" + workspace = tomllib.loads((rust_root / "Cargo.toml").read_text(encoding="utf-8")) + assert workspace["workspace"]["package"]["rust-version"] == "1.85.0", ( + "the workspace must publish the agreed Rust compatibility version" + ) + assert tuple(workspace["workspace"]["members"]) == RUST_MEMBERS, ( + "the MSRV contract must enumerate every workspace package" + ) + for member in RUST_MEMBERS: + manifest = tomllib.loads( + (rust_root / member / "Cargo.toml").read_text(encoding="utf-8") + ) + assert manifest["package"]["rust-version"] == {"workspace": True}, ( + f"{member} must inherit the root MSRV contract" + ) + + +def test_linux_debug_routes_select_the_fragment_and_injected_cargo() -> None: + """Every supported debug Cargo invocation explicitly selects the fragment.""" + output = _dry_run( + "develop", + "test-rust", + "rust-lint", + "dev-build", + "dev-test", + variables={"DEV_FAST_HOST_IS_LINUX": "yes"}, + ) + routing = "\n".join(_routing_lines(output)) + assert "DEV_FAST_CARGO=probe-cargo" in routing, "develop must inject Cargo" + assert "CARGO=tools/dev-fast/cargo" in routing, "develop must use the adapter" + direct_cargo = [ + line for line in routing.splitlines() if "probe-cargo --config" in line + ] + assert sum(line.count("probe-cargo --config") for line in direct_cargo) == 7, ( + "each standard and explicit debug Cargo invocation must select one fragment" + ) + assert all( + "--config ../tools/dev-fast/config.toml" in line for line in direct_cargo + ), "every debug Cargo command must select the approved relative fragment" + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" in routing, ( + "debug routes must select the pinned dev-fast nightly" + ) + assert "-Clink-arg=-fuse-ld=mold" in routing, ( + "explicit RUSTFLAGS must retain the Linux linker selection" + ) + + +@pytest.mark.parametrize( + "flags", + [ + pytest.param("--release --skip-install", id="release"), + pytest.param("-r --skip-install", id="short_release"), + pytest.param("--profile release --skip-install", id="profile_release"), + ], +) +def test_release_maturin_routes_do_not_select_dev_fast(flags: str) -> None: + """Release spellings cannot accidentally route Maturin through Cranelift.""" + output = _dry_run("develop", variables={"MATURIN_DEVELOP_FLAGS": flags}) + assert "tools/dev-fast/cargo" not in output, "release Maturin must skip bridge" + assert "DEV_FAST_CARGO=" not in output, "release Maturin must skip adapter env" + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in output, ( + "release Maturin must not select the dev-fast nightly" + ) + + +@settings(max_examples=24, deadline=None) +@given(st.lists(SAFE_MATURIN_FLAGS, max_size=7)) +def test_maturin_release_classification_is_order_independent(flags: list[str]) -> None: + """Generated safe flag sequences preserve the documented release boundary.""" + output = _dry_run("develop", variables={"MATURIN_DEVELOP_FLAGS": " ".join(flags)}) + is_release = _is_release_maturin_flags(flags) + assert ("tools/dev-fast/cargo" not in output) is is_release, ( + f"flags {flags!r} must {'skip' if is_release else 'select'} the dev-fast bridge" + ) + + +def test_non_linux_alternative_stays_on_the_stable_cargo_route() -> None: + """The explicit non-Linux alternative cannot acquire the Linux fragment.""" + output = _dry_run( + "develop", "test-rust", "rust-lint", variables={"DEV_FAST_HOST_IS_LINUX": ""} + ) + assert "tools/dev-fast/cargo" not in output, "non-Linux must skip bridge" + assert "--config ../tools/dev-fast/config.toml" not in output, ( + "non-Linux must not select the fragment" + ) + assert "nightly-2026-08-23" not in output, "non-Linux must use its stable route" + + +def test_whitaker_and_windows_lint_remain_fragment_free() -> None: + """Verification and cross-platform routes never inherit Linux acceleration.""" + output = _dry_run("rust-lint", "lint-windows") + whitaker = next(line for line in output.splitlines() if "whitaker --all" in line) + windows = next( + line + for line in output.splitlines() + if "--target x86_64-pc-windows-msvc" in line + ) + assert "--config" not in whitaker, "Whitaker must not select a Cargo fragment" + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in whitaker, ( + "Whitaker must keep its verifier toolchain" + ) + assert "--config" not in windows, "Windows lint must not select Linux fragment" + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in windows, ( + "Windows lint must retain its platform route" + ) + + +def test_msrv_verification_keeps_the_stable_route_fragment_free() -> None: + """The accelerated lint compiler cannot replace published-MSRV verification.""" + output = _dry_run("msrv-check") + route = next(line for line in output.splitlines() if "probe-cargo check" in line) + assert "RUSTUP_TOOLCHAIN=1.85.0" in route, "MSRV check must pin Rust 1.85.0" + assert "--config" not in route, "MSRV check must not select the fragment" + assert "nightly-2026-08-23" not in route, "MSRV check must not use nightly" + + +@pytest.mark.parametrize( + "route", + [ + pytest.param( + lambda: "\n".join([ + _dry_run("build-release"), + (repo_root() / ".github/actions/build-wheels/action.yml").read_text( + encoding="utf-8" + ), + ]), + id="release", + ), + pytest.param( + lambda: (repo_root() / ".github/workflows/coverage-main.yml").read_text( + encoding="utf-8" + ), + id="coverage", + ), + pytest.param( + lambda: _dry_run("boundary-kani", "boundary-miri", "boundary-test"), + id="verification", + ), + ], +) +def test_protected_routes_reject_dev_fast_contamination( + route: cabc.Callable[[], str], +) -> None: + """Keep release, coverage, and verification invocations on their own toolchains.""" + protected_route = route() + _assert_fragment_free(protected_route) + _assert_fragment_mutation_is_rejected(protected_route) + + +def test_ci_provisions_only_the_pinned_linux_prerequisites() -> None: + """CI installs only the approved binary prerequisites for Linux debug work.""" + action = (repo_root() / ".github/actions/setup-dev-fast/action.yml").read_text( + encoding="utf-8" + ) + workflow = (repo_root() / ".github/workflows/ci.yml").read_text(encoding="utf-8") + assert "nightly-2026-08-23" in action, "CI must install the pinned nightly" + assert "rustc-codegen-cranelift" in action, "CI must install Cranelift" + assert 'rustup component add "${component}" clippy' in action, ( + "CI must provision the lint component with the backend" + ) + assert 'archive="mold-${version}-${architecture}-linux.tar.gz"' in action, ( + "CI must derive the architecture-specific archive" + ) + assert "sha256sum --check --status" in action, "CI must verify downloaded mold" + assert "cargo install" not in action, "CI must not add a source-build fallback" + assert workflow.count("uses: ./.github/actions/setup-dev-fast") == 2, ( + "only Linux debug jobs may provision dev-fast" + ) + assert "- name: Verify Rust MSRV\n run: make msrv-check" in workflow, ( + "CI must retain independent MSRV verification" + ) diff --git a/cuprum/unittests/test_dev_fast_prerequisites.py b/cuprum/unittests/test_dev_fast_prerequisites.py new file mode 100644 index 000000000..6a31e842b --- /dev/null +++ b/cuprum/unittests/test_dev_fast_prerequisites.py @@ -0,0 +1,232 @@ +"""Exercise dev-fast prerequisites and one routed build command end to end.""" + +from __future__ import annotations + +import os +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] - controlled Make argv. +import typing as typ + +import pytest + +from tests.helpers.docs import repo_root + +if typ.TYPE_CHECKING: + from pathlib import Path + + +FRAGMENT = "tools/dev-fast/config.toml" + + +@pytest.fixture +def mold_version() -> str: + """Return the linker version recorded by the checked-in pin.""" + return (repo_root() / "tools/mold/VERSION").read_text(encoding="utf-8").strip() + + +def _installed_components(*components: str) -> str: + """Emit the component listing a provisioned toolchain reports.""" + listed = " ".join(f"'{component}'" for component in components) + return f"printf '%s\\n' {listed}" + + +def _write_program(directory: Path, name: str, body: str) -> None: + """Create one controlled executable used to mutate a prerequisite.""" + program = directory / name + program.write_text(f"#!/usr/bin/env bash\n{body}\n", encoding="utf-8") + program.chmod(0o755) + + +class _FailureCase(typ.NamedTuple): + """One prerequisite mutation and the diagnostic its failure must report.""" + + programs: dict[str, str] + variables: dict[str, str] + expected_diagnostic: str + + +@pytest.mark.parametrize( + "case", + [ + pytest.param( + _FailureCase({}, {}, "mold {version} is required"), + id="missing_mold", + ), + pytest.param( + _FailureCase( + { + "mold": "printf '%s\\n' 'mold 2.41.00'", + "rustup": "exit 0", + }, + {}, + "mold {version} is required", + ), + id="wrong_mold_version", + ), + pytest.param( + _FailureCase( + { + "mold": "printf '%s\\n' 'mold {version}'", + "rustup": "exit 0", + }, + {}, + "install rustc-codegen-cranelift", + ), + id="missing_component", + ), + pytest.param( + _FailureCase( + { + "mold": "printf '%s\\n' 'mold {version}'", + "rustup": _installed_components("rustc-codegen-cranelift"), + }, + {}, + "install clippy", + ), + id="missing_clippy", + ), + ], +) +def test_prerequisite_recipe_fails_closed_for_missing_dependencies( + tmp_path: Path, case: _FailureCase, mold_version: str +) -> None: + """Missing or mismatched prerequisites are hard failures with useful output.""" + for name, body in case.programs.items(): + _write_program(tmp_path, name, body.format(version=mold_version)) + make = shutil.which("make") + assert make is not None, "the prerequisite contract requires GNU Make on PATH" + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [ + make, + "dev-fast-check", + "DEV_FAST_HOST_IS_LINUX=yes", + *(f"{key}={value}" for key, value in case.variables.items()), + ], + check=False, + capture_output=True, + cwd=repo_root(), + env={**os.environ, "PATH": f"{tmp_path}:/usr/bin:/bin"}, + text=True, + ) + assert result.returncode != 0, "missing prerequisites must fail closed" + assert case.expected_diagnostic.format(version=mold_version) in result.stderr, ( + f"the prerequisite failure must explain how to repair it: {result.stderr}" + ) + + +def test_prerequisite_recipe_accepts_the_pinned_dependencies( + tmp_path: Path, mold_version: str +) -> None: + """The closed prerequisite gate accepts exactly the pinned linker and components.""" + _write_program(tmp_path, "mold", f"printf '%s\\n' 'mold {mold_version}'") + _write_program( + tmp_path, + "rustup", + _installed_components("rustc-codegen-cranelift", "clippy"), + ) + make = shutil.which("make") + assert make is not None, "the prerequisite contract requires GNU Make on PATH" + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [make, "dev-fast-check", "DEV_FAST_HOST_IS_LINUX=yes"], + check=False, + capture_output=True, + cwd=repo_root(), + env={**os.environ, "PATH": f"{tmp_path}:/usr/bin:/bin"}, + text=True, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + "delivery", + [ + pytest.param("command_line", id="command_line"), + pytest.param("environment", id="environment"), + ], +) +def test_recipe_ignores_an_overridden_configuration( + tmp_path: Path, delivery: str +) -> None: + """The evaluated route keeps the repository fragment whatever the caller sets. + + Both delivery mechanisms are exercised. A definition that merely looks safe + can still yield to an environment variable or to a command-line variable, + so proving only one of them leaves the other as an open redirect. + """ + redirection = str(tmp_path / "other-config.toml") + variables = [f"DEV_FAST_RUST_CONFIG={redirection}"] + environment = {**os.environ, "PATH": f"{tmp_path}:/usr/bin:/bin"} + if delivery == "command_line": + extra_arguments = variables + else: + extra_arguments = [] + environment["DEV_FAST_RUST_CONFIG"] = redirection + make = shutil.which("make") + assert make is not None, "the override contract requires GNU Make on PATH" + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [ + make, + "--dry-run", + "dev-build", + "DEV_FAST_HOST_IS_LINUX=yes", + *extra_arguments, + ], + check=False, + capture_output=True, + cwd=repo_root(), + env=environment, + text=True, + ) + assert result.returncode == 0, result.stderr + assert redirection not in result.stdout, ( + "an overridden configuration path must not reach the evaluated command" + ) + assert f"--config ../{FRAGMENT}" in result.stdout, ( + "the route must select the repository fragment despite the override" + ) + + +def test_dev_build_executes_the_approved_fragment( + tmp_path: Path, mold_version: str +) -> None: + """A Linux debug build invokes Cargo once with the explicit approved fragment.""" + _write_program(tmp_path, "mold", f"printf '%s\\n' 'mold {mold_version}'") + _write_program( + tmp_path, + "rustup", + _installed_components("rustc-codegen-cranelift", "clippy"), + ) + invocation = tmp_path / "cargo-invocation" + cargo = tmp_path / "cargo" + cargo.write_text( + '#!/usr/bin/env bash\nprintf \'%s\n\' "$@" > "$DEV_FAST_TEST_CARGO_ARGV"\n', + encoding="utf-8", + ) + cargo.chmod(0o755) + make = shutil.which("make") + assert make is not None, "the routed-build contract requires GNU Make on PATH" + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [ + make, + "dev-build", + f"CARGO={cargo}", + "DEV_FAST_HOST_IS_LINUX=yes", + ], + check=False, + capture_output=True, + cwd=repo_root(), + env={ + **os.environ, + "DEV_FAST_TEST_CARGO_ARGV": str(invocation), + "PATH": f"{tmp_path}:/usr/bin:/bin", + }, + text=True, + ) + assert result.returncode == 0, result.stderr + assert invocation.read_text(encoding="utf-8").splitlines() == [ + "--config", + "../tools/dev-fast/config.toml", + "build", + "--all-targets", + "--all-features", + ], "the executed debug build must select exactly the approved configuration" diff --git a/cuprum/unittests/test_workflow_lint.py b/cuprum/unittests/test_workflow_lint.py index 0f4e05fb8..a1fb51231 100644 --- a/cuprum/unittests/test_workflow_lint.py +++ b/cuprum/unittests/test_workflow_lint.py @@ -10,6 +10,7 @@ from __future__ import annotations import os +import shlex import stat import subprocess # ruff: ignore[suspicious-subprocess-import] - fixed Makefile targets. import typing as typ @@ -83,7 +84,13 @@ class _LintJob(typ.TypedDict, total=False): steps: list[_Step] -def _write_fake_tool(directory: pth.Path, tool: str, *, exit_code: int = 0) -> None: +def _write_fake_tool( + directory: pth.Path, + tool: str, + *, + exit_code: int = 0, + output: str = "", +) -> None: """Write an executable that records its name and arguments.""" executable = directory / tool executable.write_text( @@ -93,6 +100,7 @@ def _write_fake_tool(directory: pth.Path, tool: str, *, exit_code: int = 0) -> N ' printf \'\\t%s\' "${argument}" >> "${LINT_INVOCATION_LOG}"\n' "done\n" "printf '\\n' >> \"${LINT_INVOCATION_LOG}\"\n" + f"printf '%s' {shlex.quote(output)}\n" f"exit {exit_code}\n", encoding="utf-8", ) @@ -104,17 +112,20 @@ def _make_environment( *, tools: cabc.Iterable[str], exit_codes: cabc.Mapping[str, int] | None = None, + outputs: cabc.Mapping[str, str] | None = None, ) -> tuple[dict[str, str], pth.Path]: """Create a Makefile environment with only the requested fake tools.""" tool_directory = tmp_path / "tools" tool_directory.mkdir() invocation_log = tmp_path / "invocations.log" expected_exit_codes = exit_codes or {} + expected_outputs = outputs or {} for tool in tools: _write_fake_tool( tool_directory, tool, exit_code=expected_exit_codes.get(tool, 0), + output=expected_outputs.get(tool, ""), ) environment = { **os.environ, @@ -256,7 +267,15 @@ def test_the_workflow_lint_target_runs_both_linters(tmp_path: pth.Path) -> None: def test_the_lint_target_runs_the_workflow_linters(tmp_path: pth.Path) -> None: """The aggregate lint target reaches both GitHub Actions linters.""" environment, invocation_log = _make_environment( - tmp_path, tools=("uv", "yamllint", "actionlint") + tmp_path, + tools=("uv", "yamllint", "actionlint", "mold", "rustup"), + outputs={ + "mold": "mold 2.41.0\n", + "rustup": ( + "rustc-codegen-cranelift-x86_64-unknown-linux-gnu (installed)\n" + "clippy-x86_64-unknown-linux-gnu (installed)\n" + ), + }, ) overrides = tmp_path / "lint-target-overrides.mk" overrides.write_text( @@ -273,6 +292,8 @@ def test_the_lint_target_runs_the_workflow_linters(tmp_path: pth.Path) -> None: assert completed.returncode == 0, completed.stderr assert invocation_log.read_text(encoding="utf-8").splitlines() == [ "uv\trun\twhich\truff", + "mold\t--version", + "rustup\tcomponent\tlist\t--installed\t--toolchain\tnightly-2026-08-23", f"yamllint\t--strict\t--config-file\t.yamllint.yml\t{_WORKFLOW_DIRECTORY}", "actionlint\t-config-file\t.github/actionlint.yaml", ] diff --git a/docs/adr-012-linux-dev-fast-routing.md b/docs/adr-012-linux-dev-fast-routing.md new file mode 100644 index 000000000..c737a1c34 --- /dev/null +++ b/docs/adr-012-linux-dev-fast-routing.md @@ -0,0 +1,79 @@ +# Architectural decision record (ADR) 012: Linux dev-fast routing + +## Status + +Accepted on 2026-09-17. Cuprum selects the dev-fast Cargo configuration only +for supported Linux debug work through explicit Makefile routes. + +## Date + +2026-09-17. + +## Context and problem statement + +Rust compile latency slows the ordinary development loop, but an accelerated +compiler backend and Linux linker cannot become ambient Cargo configuration. +Release packaging, coverage, formal verification, Whitaker, platform-specific +linting, and published minimum-supported Rust version (MSRV) verification need +the repository's stable backend and their existing toolchain contracts. Cargo +also lets `RUSTFLAGS` replace configured flags, so test routing must preserve +mold selection when it supplies warning and code-generation flags. + +Maturin accepts a Cargo executable through `CARGO`, but cannot supply Cargo's +configuration-file option. The configuration must therefore be injected without +letting build scripts recursively invoke the adapter or callers substitute a +different configuration. + +## Decision + +The Makefile pins a separate dev-fast nightly and requires its +`rustc-codegen-cranelift` component. On Linux, standard debug `develop`, +`test-rust`, and Rust documentation and Clippy invocations select the explicit +`tools/dev-fast/config.toml` fragment. `dev-build` and `dev-test` expose the +same route. The fragment stays outside Cargo's auto-discovered configuration. + +The fragment applies Cranelift only to the dev profile and supplies the Linux +mold linker flag. Makefile test routes repeat that linker flag whenever they set +`RUSTFLAGS`, because Cargo chooses that environment source instead of merging +the fragment's target flags. `make dev-fast-check` validates the selected +fragment, the version from `tools/mold/VERSION`, and the installed component +before an accelerated route runs. + +The routed fragment path is internal state, not caller input. The Makefile +derives it from one constant assigned with GNU Make's `override` directive, so +neither a make variable on the command line nor an environment variable of the +same name can replace it. A plain or `?=` assignment would not hold: an +ordinary environment variable replaces a `?=` definition, and only `override` +outranks a command-line variable. Without it, a caller could route an +accelerated build through a configuration the project never reviewed while the +prerequisite gate still passed. The prerequisite gate checks that fixed +constant. Anything needing a different Cargo configuration is outside this +route and does not use it. + +The Linux-only `tools/dev-fast/cargo` adapter receives the real Cargo +executable from Make and derives the fragment from its own location, so the +configuration it passes is a property of the checked-out tree rather than of +its environment. It adds exactly one `--config` option, exports the real +executable as `CARGO` for child build scripts, rejects caller configuration +options, and replaces itself with Cargo to preserve argv and exit status. + +The shared CI action downloads only checksum-pinned upstream mold binaries for +supported Linux architectures. It has no source-build fallback. CI installs the +same nightly and component for debug jobs. The stable route remains mandatory +for release, coverage, verification, Whitaker, MSRV checks, and macOS and +Windows. Every workspace member inherits Rust 1.85.0, and `make msrv-check` +compiles every target at that version without the fragment. + +## Consequences + +Contributors gain faster Linux debug builds after installing the explicit +prerequisites. Direct Cargo use remains unchanged unless the caller selects the +fragment deliberately. Unsupported platforms retain a clear stable route rather +than a partially working linker setup. + +The contract tests execute the composite-action shell step with controlled +commands, test both supported architectures and failure boundaries, model +release flag orderings, and exercise adapter argv and environment behaviour. +They complement Make dry-run tests, which prove routing text but not shell +installer behaviour. Pin changes must update the version metadata, checksums, +configuration digest, tests, and this decision if the routing boundary changes. diff --git a/docs/contents.md b/docs/contents.md index 8c6e8e7a6..bce796ee0 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -54,6 +54,8 @@ open each document. opt-in executor-hop tracing without extending the pump event channel. - [ADR-011: Audited Rust safety boundaries][adr-011] - accepted decision to isolate native resource operations from safe stream policy. +- [ADR-012: Linux dev-fast routing][adr-012] - accepted decision to confine + accelerated Cargo builds to explicit, supported Linux debug routes. ## Planning and validation references @@ -82,6 +84,7 @@ open each document. [adr-009]: adr-009-enforce-oxford-spelling-in-source.md [adr-010]: adr-010-rust-pump-hop-span.md [adr-011]: adr-011-audited-rust-boundaries.md +[adr-012]: adr-012-linux-dev-fast-routing.md [local-validation]: local-validation-of-github-actions-with-act-and-pytest.md [migration-020]: migration-0.2.0.md [rust-boundary-verification]: rust-boundary-verification.md diff --git a/docs/developers-guide.md b/docs/developers-guide.md index ee08a066d..eb9562634 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -395,6 +395,62 @@ When updating Rust dependencies, keep the requested version aligned to the patch baseline already present in `Cargo.lock`. This keeps lockfile updates focused, small, and easy to review. +## Linux debug-build acceleration + +[ADR-012](adr-012-linux-dev-fast-routing.md) records the routing boundary and +its compatibility rationale. + +Cuprum's Rust `1.85.0` pin remains the compatibility, release, coverage, +verification, and Whitaker toolchain. On Linux only, ordinary debug Rust work +uses the separately pinned `nightly-2026-08-23` and the +`rustc-codegen-cranelift` component through `tools/dev-fast/config.toml`; the +Clippy portion of `make rust-lint` additionally requires the `clippy` component +on that nightly. The fragment is an approved byte-for-byte local extension from +`leynos/netsuke@924cb215d3841048dbf6649767a510d2a5dfb1b7`, recorded by +Concordat issue 157; its SHA-256 is +`8619efda5ea1c3232f413ae96ff56869ab6b2b7cd5bdef5a001ad16ebeac23a5`. + +Run `make dev-fast-check` before the first Linux accelerated build. It requires +both the `rustc-codegen-cranelift` and `clippy` components on the dev-fast +nightly, plus the pinned `mold` 2.41.0 linker. The approved binary route is the +checksum-verified upstream release archive; it never permits a source-build +fallback. `make develop`, `make test-rust`, and the Cargo doc and Clippy +portion of `make rust-lint` select the fragment on a compatible Linux host. +`make dev-build` and `make dev-test` expose the same route explicitly. Direct +Cargo invocations leave toolchain selection to the caller; supplying the +fragment does not select a toolchain. + +The routed path is fixed, not configurable. The Makefile resolves the fragment +from one internal constant that no make variable or environment variable can +replace, so `make test-rust DEV_FAST_RUST_CONFIG=/tmp/other.toml` cannot +substitute a different configuration and still report success. Anything needing +a different Cargo configuration is outside this route and does not use it. + +Changing the fragment means changing the bytes that digest authorizes. Update +`FRAGMENT_SHA256` in `cuprum/unittests/test_dev_fast_contract.py` and the +digest quoted above in the same commit, or the contract test fails. + +Every workspace package inherits `rust-version = "1.85.0"` from the root +manifest. Cargo treats that as the publication and compatibility contract, and +MSRV-aware Clippy lints use it while the Linux debug lint route runs on the +dev-fast nightly. `make msrv-check` separately compiles every workspace target +on Rust `1.85.0` without the fragment, so the accelerated lint compiler cannot +replace compatibility verification. + +Maturin accepts a Cargo executable but cannot pass Cargo's configuration-file +flag itself. The Linux-only `tools/dev-fast/cargo` adapter receives the injected +`CARGO` executable, adds exactly one approved `--config` path, and then +replaces itself with Cargo. It rejects a second configuration argument. This +small POSIX adapter is intentionally outside the normal Python scripting +standard because it must preserve Cargo's exact argv and exit status. + +macOS and Windows use the stable backend as the approved alternative. The +Windows cross-target lint, wheel builds, benchmark release builds, coverage, +verification, and Whitaker never select the fragment. The fragment documents +Cuprum's ownership boundary: Makefile pins the dev-fast nightly, while +`tools/mold/VERSION` pins the linker release. The stable support promise +remains independently verified. + ## Tar and rsync builder helpers `TarCreateOptions.compression` in `cuprum/builders/tar.py` selects one member diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 4b77c83ce..1e47a1853 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -40,6 +40,8 @@ _Figure 1: Simplified repository tree for contributor orientation._ | `rust/cuprum-native-io/` | Audited native descriptor/handle ownership, single-call I/O, progress kernels, and platform test fixtures. | | `test-wheelhouse/` | Local wheel artefacts used by validation workflows and compatibility tests. | | `tests/` | Behavioural, integration, and regression tests for the Python package and user-visible workflows. | +| `tools/dev-fast/` | Linux-only explicit Cargo adapter and approved Cranelift configuration for debug work. | +| `tools/mold/` | Pinned checksums for the prebuilt Linux linker used by the dev-fast route. | | `AGENTS.md` | Repository-specific assistant and contributor instructions. | | `Makefile` | Canonical entry point for build, format, lint, typecheck, test, documentation lint, and diagram validation gates. | | `pyproject.toml` | Python project metadata, dependency declarations, and tool configuration. | diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5ac168553..12c5596fb 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,6 +2,11 @@ resolver = "2" members = ["cuprum-rust", "cuprum-streams", "cuprum-native-io"] +[workspace.package] +# This public compatibility contract also supplies Clippy's MSRV-aware lints +# when Linux debug linting uses the separately pinned dev-fast nightly. +rust-version = "1.85.0" + [workspace.lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/rust/cuprum-native-io/Cargo.toml b/rust/cuprum-native-io/Cargo.toml index 553cc2534..0fcf40a0b 100644 --- a/rust/cuprum-native-io/Cargo.toml +++ b/rust/cuprum-native-io/Cargo.toml @@ -2,6 +2,7 @@ name = "cuprum-native-io" version = "0.1.0" edition = "2024" +rust-version.workspace = true license = "ISC" description = "Audited native resource and syscall boundary for Cuprum" publish = false diff --git a/rust/cuprum-native-io/src/lib.rs b/rust/cuprum-native-io/src/lib.rs index 7d15cd03a..befa1cf0b 100644 --- a/rust/cuprum-native-io/src/lib.rs +++ b/rust/cuprum-native-io/src/lib.rs @@ -195,8 +195,9 @@ pub(crate) fn fd_is_open(fd: i32) -> io::Result { } let error = io::Error::last_os_error(); match error.raw_os_error() { - // A delivered signal interrupted the call; observe again. - Some(libc::EINTR) => continue, + // A delivered signal interrupted the call. The match ends the loop + // body, so falling through here is the retry the `continue` named. + Some(libc::EINTR) => {} Some(libc::EBADF) => return Ok(false), _ => return Err(error), } diff --git a/rust/cuprum-rust/Cargo.toml b/rust/cuprum-rust/Cargo.toml index 56082b10d..6e13f878b 100644 --- a/rust/cuprum-rust/Cargo.toml +++ b/rust/cuprum-rust/Cargo.toml @@ -2,6 +2,7 @@ name = "cuprum-rust" version = "0.1.0" edition = "2024" +rust-version.workspace = true license = "ISC" description = "Optional Rust extension for cuprum stream operations" publish = false diff --git a/rust/cuprum-streams/Cargo.toml b/rust/cuprum-streams/Cargo.toml index 8f7283591..83250543c 100644 --- a/rust/cuprum-streams/Cargo.toml +++ b/rust/cuprum-streams/Cargo.toml @@ -2,6 +2,7 @@ name = "cuprum-streams" version = "0.1.0" edition = "2024" +rust-version.workspace = true license = "ISC" description = "Safe stream orchestration for Cuprum" publish = false diff --git a/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.rs b/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.rs index 42f740bc5..a834393f1 100644 --- a/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.rs +++ b/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.rs @@ -2,15 +2,20 @@ use cuprum_native_io::with_owned_writer; -fn escape_writer(writer: W, replacement: W) -> W { - with_owned_writer(&(), writer, |_, scoped_writer| { - std::mem::replace(scoped_writer, replacement) - }) -} - fn main() -> Result<(), std::io::Error> { let (_, writer) = cuprum_native_io::pipe()?; let (_, replacement) = cuprum_native_io::pipe()?; drop(escape_writer(writer, replacement)); Ok(()) } + +fn replace_scoped_writer(destination: &mut T, replacement: T) -> T { + std::mem::replace(destination, replacement) +} + +fn escape_writer(writer: W, replacement: W) -> W { + with_owned_writer(&(), writer, |_, scoped_writer| { + let escaped = replace_scoped_writer(scoped_writer, replacement); + escaped + }) +} diff --git a/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr b/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr index dd9951218..880936ce0 100644 --- a/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr +++ b/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr @@ -1,12 +1,15 @@ error[E0308]: mismatched types - --> tests/ui/fail/replace_scoped_writer.rs:7:27 - | -7 | std::mem::replace(scoped_writer, replacement) - | ----------------- ^^^^^^^^^^^^^ types differ in mutability - | | - | arguments to this function are incorrect - | - = note: expected mutable reference `&mut W` - found reference `&W` + --> tests/ui/fail/replace_scoped_writer.rs:18:45 + | +18 | let escaped = replace_scoped_writer(scoped_writer, replacement); + | --------------------- ^^^^^^^^^^^^^ types differ in mutability + | | + | arguments to this function are incorrect + | + = note: expected mutable reference `&mut W` + found reference `&W` note: function defined here - --> $RUST/core/src/mem/mod.rs + --> tests/ui/fail/replace_scoped_writer.rs:12:4 + | +12 | fn replace_scoped_writer(destination: &mut T, replacement: T) -> T { + | ^^^^^^^^^^^^^^^^^^^^^ ------------------- diff --git a/tests/__snapshots__/test_dev_fast_action.ambr b/tests/__snapshots__/test_dev_fast_action.ambr new file mode 100644 index 000000000..5803c24c3 --- /dev/null +++ b/tests/__snapshots__/test_dev_fast_action.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_install_selects_the_pinned_archive_and_exports_its_path[aarch64] + dict({ + 'rustup': list([ + 'toolchain', + 'install', + 'nightly-2026-08-23', + '--profile', + 'minimal', + 'component', + 'add', + 'rustc-codegen-cranelift', + 'clippy', + '--toolchain', + 'nightly-2026-08-23', + ]), + 'tar': list([ + '--extract', + '--gzip', + '--strip-components=1', + '--directory', + '/.local', + '--file', + '/runner-temp/mold-2.41.0-aarch64-linux.tar.gz', + ]), + }) +# --- +# name: test_install_selects_the_pinned_archive_and_exports_its_path[x86_64] + dict({ + 'rustup': list([ + 'toolchain', + 'install', + 'nightly-2026-08-23', + '--profile', + 'minimal', + 'component', + 'add', + 'rustc-codegen-cranelift', + 'clippy', + '--toolchain', + 'nightly-2026-08-23', + ]), + 'tar': list([ + '--extract', + '--gzip', + '--strip-components=1', + '--directory', + '/.local', + '--file', + '/runner-temp/mold-2.41.0-x86_64-linux.tar.gz', + ]), + }) +# --- diff --git a/tests/test_dev_fast_action.py b/tests/test_dev_fast_action.py new file mode 100644 index 000000000..918546521 --- /dev/null +++ b/tests/test_dev_fast_action.py @@ -0,0 +1,232 @@ +"""Execute the Linux dev-fast composite action under controlled shell fixtures.""" + +from __future__ import annotations + +import os +import typing as typ +from pathlib import Path + +import pytest + +from tests.helpers.composite_actions import StepResult, run_step, step_script + +if typ.TYPE_CHECKING: + from syrupy.assertion import SnapshotAssertion + +ACTION = ".github/actions/setup-dev-fast" +INSTALL_STEP = "Install the pinned backend and linker" +MOLD_VERSION = "2.41.0" +CHECKSUM = "a" * 64 + + +def _write_program(directory: Path, name: str, body: str) -> None: + """Write one controlled shell command for an action-step test.""" + program = directory / name + program.write_text(f"#!/usr/bin/env bash\nset -eu\n{body}\n", encoding="utf-8") + program.chmod(0o755) + + +def _write_mold_metadata(directory: Path, checksum_lines: str) -> None: + """Create the action's repository-relative linker metadata.""" + metadata_directory = directory / "tools/mold" + metadata_directory.mkdir(parents=True) + (metadata_directory / "VERSION").write_text(f"{MOLD_VERSION}\n", encoding="utf-8") + (metadata_directory / "SHA256SUMS").write_text(checksum_lines, encoding="utf-8") + + +def _action_environment( + tmp_path: Path, architecture: str, *, sha_status: int = 0 +) -> dict[str, str]: + """Install fake trusted commands and return the emulated runner environment.""" + commands = tmp_path / "bin" + commands.mkdir() + records = tmp_path / "records" + records.mkdir() + _write_program(commands, "uname", f"printf '%s\\n' '{architecture}'") + _write_program( + commands, "rustup", 'printf \'%s\\n\' "$@" >> "$DEV_FAST_RECORDS/rustup"' + ) + _write_program( + commands, + "curl", + "output=''\n" + "while [ $# -gt 0 ]; do\n" + ' if [ "$1" = --output ]; then output="$2"; shift 2; continue; fi\n' + ' printf \'%s\\n\' "$1" >> "$DEV_FAST_RECORDS/curl"\n' + " shift\n" + "done\n" + 'mkdir -p "$(dirname "$output")"\n' + 'printf archive > "$output"', + ) + _write_program( + commands, + "sha256sum", + 'cat > "$DEV_FAST_RECORDS/checksum"\nexit "$DEV_FAST_SHA_STATUS"', + ) + _write_program(commands, "tar", 'printf \'%s\\n\' "$@" > "$DEV_FAST_RECORDS/tar"') + github_path = tmp_path / "github_path" + return { + "PATH": f"{commands}:{os.environ['PATH']}", + "DEV_FAST_RECORDS": str(records), + "DEV_FAST_SHA_STATUS": str(sha_status), + "RUNNER_TEMP": str(tmp_path / "runner-temp"), + "GITHUB_PATH": str(github_path), + } + + +def _run_install(tmp_path: Path, environment: dict[str, str]) -> StepResult: + """Execute the action's installation step with the supplied fake tools.""" + return run_step( + step_script(ACTION, INSTALL_STEP), workdir=tmp_path, environment=environment + ) + + +@pytest.mark.parametrize( + ("architecture", "archive"), + [ + pytest.param("x86_64", "mold-2.41.0-x86_64-linux.tar.gz", id="x86_64"), + pytest.param("aarch64", "mold-2.41.0-aarch64-linux.tar.gz", id="aarch64"), + ], +) +def test_install_selects_the_pinned_archive_and_exports_its_path( + tmp_path: Path, architecture: str, archive: str, snapshot: SnapshotAssertion +) -> None: + """Supported architectures download, check, extract, and expose one archive.""" + _write_mold_metadata(tmp_path, f"{CHECKSUM} {archive}\n") + environment = _action_environment(tmp_path, architecture) + result = _run_install(tmp_path, environment) + records = Path(environment["DEV_FAST_RECORDS"]) + assert result.returncode == 0, result.stderr + rustup_arguments = ( + records.joinpath("rustup").read_text(encoding="utf-8").splitlines() + ) + assert rustup_arguments[:2] == ["toolchain", "install"], ( + "the action must install its dev-fast toolchain" + ) + assert "rustc-codegen-cranelift" in rustup_arguments, ( + "the action must install the Cranelift component" + ) + assert ( + records + .joinpath("curl") + .read_text(encoding="utf-8") + .splitlines()[-1] + .endswith(f"/v{MOLD_VERSION}/{archive}") + ), "the action must fetch the selected upstream archive" + assert records.joinpath("checksum").read_text(encoding="utf-8") == ( + f"{CHECKSUM} {environment['RUNNER_TEMP']}/{archive}\n" + ), "the action must verify the pinned checksum against its downloaded archive" + tar_arguments = records.joinpath("tar").read_text(encoding="utf-8").splitlines() + installation_commands = { + "rustup": rustup_arguments, + "tar": [ + argument.replace(str(tmp_path), "") for argument in tar_arguments + ], + } + assert installation_commands == snapshot, ( + "the backend installation and extraction command contracts changed" + ) + assert Path(environment["GITHUB_PATH"]).read_text(encoding="utf-8") == ( + f"{tmp_path}/.local/bin\n" + ), "the action must expose the installed linker to later workflow steps" + + +@pytest.mark.parametrize("architecture", ["amd64", "arm64"]) +def test_install_accepts_github_runner_architecture_aliases( + tmp_path: Path, architecture: str +) -> None: + """GitHub runner architecture aliases map to their canonical linker archive.""" + canonical = "x86_64" if architecture == "amd64" else "aarch64" + archive = f"mold-{MOLD_VERSION}-{canonical}-linux.tar.gz" + _write_mold_metadata(tmp_path, f"{CHECKSUM} {archive}\n") + environment = _action_environment(tmp_path, architecture) + result = _run_install(tmp_path, environment) + assert result.returncode == 0, result.stderr + + +def test_install_rejects_an_unsupported_architecture_before_provisioning( + tmp_path: Path, +) -> None: + """An unsupported runner fails before installing or downloading anything.""" + _write_mold_metadata(tmp_path, "") + environment = _action_environment(tmp_path, "riscv64") + result = _run_install(tmp_path, environment) + records = Path(environment["DEV_FAST_RECORDS"]) + assert result.returncode != 0, "unsupported architecture must fail closed" + assert "unsupported mold architecture: riscv64" in result.stderr, ( + "the unsupported architecture diagnostic must name the rejected architecture" + ) + assert not records.joinpath("rustup").exists(), ( + "unsupported hosts must not install nightly" + ) + assert not records.joinpath("curl").exists(), ( + "unsupported hosts must not download the linker" + ) + + +@pytest.mark.parametrize( + "checksum_lines", + [ + pytest.param("", id="zero_entries"), + pytest.param( + "\n".join([ + f"{CHECKSUM} mold-2.41.0-x86_64-linux.tar.gz", + f"{'b' * 64} mold-2.41.0-x86_64-linux.tar.gz", + "", + ]), + id="multiple_entries", + ), + ], +) +def test_install_requires_exactly_one_checksum_entry( + tmp_path: Path, checksum_lines: str +) -> None: + """The action refuses missing and ambiguous checksum metadata before download.""" + _write_mold_metadata(tmp_path, checksum_lines) + environment = _action_environment(tmp_path, "x86_64") + result = _run_install(tmp_path, environment) + records = Path(environment["DEV_FAST_RECORDS"]) + assert result.returncode != 0, "checksum metadata must identify exactly one archive" + assert not records.joinpath("curl").exists(), ( + "ambiguous checksums must stop download" + ) + + +def test_install_propagates_a_download_failure(tmp_path: Path) -> None: + """A failed curl command stops before checksum verification or extraction.""" + archive = "mold-2.41.0-x86_64-linux.tar.gz" + _write_mold_metadata(tmp_path, f"{CHECKSUM} {archive}\n") + environment = _action_environment(tmp_path, "x86_64") + commands = Path(environment["PATH"].split(":", maxsplit=1)[0]) + _write_program(commands, "curl", "printf 'download failed\\n' >&2\nexit 22") + result = _run_install(tmp_path, environment) + records = Path(environment["DEV_FAST_RECORDS"]) + assert result.returncode == 22, "curl's failure status must fail the action" + assert "download failed" in result.stderr, ( + "the action must preserve curl's download diagnostic" + ) + assert not records.joinpath("checksum").exists(), ( + "failed downloads must not be checked" + ) + assert not records.joinpath("tar").exists(), ( + "failed downloads must not be extracted" + ) + + +def test_install_propagates_a_checksum_mismatch(tmp_path: Path) -> None: + """A checksum failure stops before archive extraction or path export.""" + archive = "mold-2.41.0-x86_64-linux.tar.gz" + _write_mold_metadata(tmp_path, f"{CHECKSUM} {archive}\n") + environment = _action_environment(tmp_path, "x86_64", sha_status=1) + result = _run_install(tmp_path, environment) + records = Path(environment["DEV_FAST_RECORDS"]) + assert result.returncode == 1, "checksum mismatch must fail the action" + assert records.joinpath("checksum").exists(), ( + "the downloaded archive must be checked" + ) + assert not records.joinpath("tar").exists(), ( + "mismatched archives must not be extracted" + ) + assert not Path(environment["GITHUB_PATH"]).exists(), ( + "a rejected archive must not expose an installation path" + ) diff --git a/tools/dev-fast/cargo b/tools/dev-fast/cargo new file mode 100755 index 000000000..4a693834a --- /dev/null +++ b/tools/dev-fast/cargo @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Cargo executable adapter used only by Linux `maturin develop` invocations. +# +# Maturin accepts the Cargo executable through CARGO but cannot itself pass +# Cargo's configuration-file form of --config. This is intentionally a small +# POSIX adapter, rather than a project automation script: it must replace +# itself with Cargo so the selected executable's argv and exit status survive +# unchanged. Make confines it to the Linux dev-fast route; unsupported hosts +# keep the stable backend and never execute this file. + +set -euo pipefail + +if [ -z "${DEV_FAST_CARGO:-}" ]; then + printf '%s\n' 'dev-fast: DEV_FAST_CARGO must name one Cargo executable' >&2 + exit 2 +fi + +# Derived from this adapter's own location, never from the environment. A +# caller-supplied DEV_FAST_CONFIG is ignored: this adapter is the process that +# must actually use the reviewed fragment, so a variable that redirected it +# would make the guarantee below false rather than merely configurable. +adapter_directory=$(cd -- "$(dirname -- "$0")" && pwd) +fragment="$adapter_directory/config.toml" + +if [ ! -f "$fragment" ]; then + printf 'dev-fast: the repository configuration is missing: %s\n' "$fragment" >&2 + exit 2 +fi + +for argument in "$@"; do + case "$argument" in + --config | --config=*) + printf '%s\n' 'dev-fast: Cargo configuration is selected by this adapter' >&2 + exit 2 + ;; + esac +done + +# Cargo-invoked build scripts inherit CARGO. Point them to the injected real +# executable so they cannot recursively enter this adapter. +export CARGO="$DEV_FAST_CARGO" +exec "$DEV_FAST_CARGO" --config "$fragment" "$@" diff --git a/tools/dev-fast/config.toml b/tools/dev-fast/config.toml new file mode 100644 index 000000000..d14a20163 --- /dev/null +++ b/tools/dev-fast/config.toml @@ -0,0 +1,30 @@ +# Opt-in Cargo configuration fragment for accelerated local debug builds. +# +# This file is deliberately kept out of `.cargo/config.toml`, which Cargo +# auto-discovers. Cranelift and a Linux-only linker must not apply to release +# packaging, coverage, and the formal-verification toolchains, which must keep +# the supported LLVM backend and platform linker. This fragment is instead +# passed explicitly with `cargo --config tools/dev-fast/config.toml ...` from +# the `make dev-*` targets. +# +# Makefile pins the dev-fast nightly through `DEV_FAST_TOOLCHAIN`; only the +# mold release is pinned in `tools/mold/VERSION`. See +# `docs/developers-guide.md` for the ownership boundary and composition rules. + +[unstable] +codegen-backend = true + +# Cranelift trades runtime performance for compile speed, so it is applied to +# the dev profile only. `make dev-*` never builds release artefacts. +[profile.dev] +codegen-backend = "cranelift" + +# mold ships for Linux only, so the flag is gated behind a target `cfg`. On +# macOS and Windows the table simply does not apply and the platform default +# linker is used; `make dev-fast-check` reports that fallback explicitly rather +# than leaving it implicit. +# +# Cargo picks a single rustflags source rather than merging them, so anything +# this table must carry has to be named here in full; nothing is inherited. +[target.'cfg(target_os = "linux")'] +rustflags = ["-Clink-arg=-fuse-ld=mold"] diff --git a/tools/mold/SHA256SUMS b/tools/mold/SHA256SUMS new file mode 100644 index 000000000..0e50c4470 --- /dev/null +++ b/tools/mold/SHA256SUMS @@ -0,0 +1,2 @@ +a3696680d99e692970590a178bc3a33d78d60d1c6dc9db7a11b557b02b751f5d mold-2.41.0-x86_64-linux.tar.gz +946de2774b06a71346bd59b55fddba610b65b8d93c3a4a1559cc84e103472710 mold-2.41.0-aarch64-linux.tar.gz diff --git a/tools/mold/VERSION b/tools/mold/VERSION new file mode 100644 index 000000000..2d4c52ede --- /dev/null +++ b/tools/mold/VERSION @@ -0,0 +1 @@ +2.41.0 diff --git a/typos.local.toml b/typos.local.toml index 3d77459a8..6c5d7a6e5 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -42,6 +42,44 @@ ignore = [ '--artifact-server-path(?:[=\s"`]|$)', '`color`', '\brust-analyzer\b', + # `mold` is the fixed external linker name. Typos tokenizes words before + # applying this list, so each expression carries enough linker-specific + # syntax to retain ordinary-prose checks for the word on its own. + '`mold`', + '"mold"', + 'checksum-verified mold binary', + 'unsupported mold architecture', + 'Only the Linux debug route selects Cranelift and mold', + '\bDEV_FAST_MOLD_VERSION(?:_(?:FILE|PATTERN))?\b', + '\bMOLD_VERSION\b', + '_write_mold_metadata', + '\bmissing_mold\b', + '\bwrong_mold_version\b', + # The prerequisite tests name their fixture and template placeholders after + # the linker; each form carries identifier or template syntax, so prose uses + # of the word on its own remain flagged. + '\bmold_version\b', + '\bmold \{version\}', + '\bmold \{mold_version\}', + 'command -v mold', + 'mold --version', + '\^mold \$', + 'mold %s is required', + '-fuse-ld=mold', + '\bmold 2\.41\.0\b', + '\bmold 2\.41\.00\b', + '"mold\\t--version"', + '\bmold-\$\{version\}-\$\{architecture\}-linux\.tar\.gz\b', + '\bmold-\{MOLD_VERSION\}-\{canonical\}-linux\.tar\.gz\b', + '\bmold-\d+\.\d+\.\d+-(?:x86_64|aarch64)-linux\.tar\.gz\b', + '\bmold \{MOLD_VERSION\}', + 'mold selection', + 'mold linker flag', + 'mold release', + 'mold ships', + 'upstream mold binaries', + 'downloaded mold', + '\btools/mold/', ] # ADR 009 requires en-GB-oxendict spelling in source identifiers, so the fenced diff --git a/typos.toml b/typos.toml index 13605be2f..a35018209 100644 --- a/typos.toml +++ b/typos.toml @@ -33,24 +33,56 @@ locale = "en-gb" extend-ignore-re = [ "\"artifact\"", "\"artifacts\"", + "\"mold\"", + "\"mold\\\\t--version\"", "--artifact-name(?:[=\\s\"`]|$)", "--artifact-server-path(?:[=\\s\"`]|$)", + "-fuse-ld=mold", "/artifacts(?:[/?#\"`]|$)", "Azure\\s+Architecture\\s+Center\\s*\\|\\s*Microsoft\\s+Learn\\b", "GitHub\\s+Flavored\\s+Markdown\\b(?:\\s\\(GFM\\))?", + "Only the Linux debug route selects Cranelift and mold", "Upload main benchmark baseline artifact", "\\(\"ises\", \"izes\"\\)", "\\(\"teh\", \"(?:the|ten)\"\\)", + "\\^mold \\$", + "\\bDEV_FAST_MOLD_VERSION(?:_(?:FILE|PATTERN))?\\b", "\\bFORCE_COLOR\\b", + "\\bMOLD_VERSION\\b", "\\bartifact-name\\b", "\\bartifact_dir\\b", "\\bbuild_native_wheel_artifact\\b", + "\\bmissing_mold\\b", + "\\bmold 2\\.41\\.00\\b", + "\\bmold 2\\.41\\.0\\b", + "\\bmold \\{MOLD_VERSION\\}", + "\\bmold \\{mold_version\\}", + "\\bmold \\{version\\}", + "\\bmold-\\$\\{version\\}-\\$\\{architecture\\}-linux\\.tar\\.gz\\b", + "\\bmold-\\d+\\.\\d+\\.\\d+-(?:x86_64|aarch64)-linux\\.tar\\.gz\\b", + "\\bmold-\\{MOLD_VERSION\\}-\\{canonical\\}-linux\\.tar\\.gz\\b", + "\\bmold_version\\b", "\\brust-analyzer\\b", + "\\btools/mold/", + "\\bwrong_mold_version\\b", + "_write_mold_metadata", "`color`", + "`mold`", "b\"ond\\\\n\"", "baseline-artifact", + "checksum-verified mold binary", + "command -v mold", + "downloaded mold", "mappings\\[\"organise\"\\]", + "mold %s is required", + "mold --version", + "mold linker flag", + "mold release", + "mold selection", + "mold ships", "types: \\[opened, reopened, synchronize, labeled, ready_for_review\\]", + "unsupported mold architecture", + "upstream mold binaries", ] [type.markdown]