diff --git a/AGENTS.md b/AGENTS.md index 064ed377e..deb40b334 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,7 +229,9 @@ working on the Rust portions of the project: RUSTFLAGS="-D warnings" cargo test --all-targets --all-features ``` - running the Rust test suite with warnings denied. Use `make fmt` + running the Rust test suite with warnings denied. It then runs Cargo + doctests separately with all features, because Nextest does not execute + doctests. Use `make fmt` (`cargo +nightly-2026-05-28 fmt --all`) to apply formatting fixes reported by the formatter check alongside the Python and Markdown formatters. - The project toolchain stays pinned to Rust `1.85.0` and declares `rustfmt`, diff --git a/Makefile b/Makefile index 648704477..af4e76410 100644 --- a/Makefile +++ b/Makefile @@ -61,7 +61,8 @@ RUSTFMT_CARGO ?= $(CARGO) +$(RUSTFMT_TOOLCHAIN) WHITAKER ?= whitaker BUILD_JOBS ?= RUST_FLAGS ?= -D warnings -RUSTDOC_FLAGS ?= -D warnings +RUSTDOC_FLAGS ?= --cfg docsrs -D warnings +DOCTEST_RUSTDOC_FLAGS = $(RUSTDOC_FLAGS) $(if $(DEV_FAST_HOST_IS_LINUX),-Zunstable-options --display-doctest-warnings --doctest-build-arg=-D --doctest-build-arg=warnings) CARGO_FLAGS ?= --all-targets --all-features CLIPPY_FLAGS ?= $(CARGO_FLAGS) -- $(RUST_FLAGS) DOC_FLAGS ?= --jobs 1 @@ -70,6 +71,7 @@ DOC_FLAGS ?= --jobs 1 # billed for, and never above it. TEST_JOBS ?= 1 TEST_FLAGS ?= $(CARGO_FLAGS) --jobs $(TEST_JOBS) +DOCTEST_FLAGS ?= --workspace --doc --all-features $(BUILD_JOBS) TEST_RUSTFLAGS ?= $(RUST_FLAGS) -C codegen-units=1 WHITAKER_CARGO_FLAGS ?= $(CARGO_FLAGS) --jobs 1 WHITAKER_RUSTFLAGS ?= $(RUST_FLAGS) -C codegen-units=1 @@ -104,6 +106,7 @@ PYTEST_TARGETS ?= cuprum/unittests/test_*.py \ tests/test_ci_*.py \ tests/test_native_sdist.py \ scripts/tests/test_boundary_*.py \ + scripts/tests/test_rust_lint_baseline_contract.py \ tests/behaviour/test_[a-h]*.py \ tests/behaviour/test_[i-r]*.py \ tests/behaviour/test_[s-z]*.py @@ -361,6 +364,7 @@ test-rust: $(RUST_DEBUG_PREREQUISITE) ## Run the Rust suite 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)" $(RUST_DEBUG_CARGO) test $(TEST_FLAGS) $(BUILD_JOBS); \ fi + cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTDOCFLAGS="$(DOCTEST_RUSTDOC_FLAGS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(RUST_DEBUG_CARGO) test $(DOCTEST_FLAGS) msrv-check: ## Verify every Rust target compiles on the published MSRV cd $(RUST_DIR) && $(MSRV_CARGO_COMMAND) check --workspace --all-targets --all-features diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index 7404b082f..f5e65d134 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -62,6 +62,12 @@ def _routing_lines(output: str) -> list[str]: ] +def _assert_required_tokens(route: str, tokens: tuple[str, ...], subject: str) -> None: + """Require every token that makes a routed command's contract binding.""" + missing = tuple(token for token in tokens if token not in route) + assert not missing, f"{subject} omitted required routing tokens: {missing}" + + 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}" @@ -198,7 +204,7 @@ def test_linux_debug_routes_select_the_fragment_and_injected_cargo() -> None: 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, ( + assert sum(line.count("probe-cargo --config") for line in direct_cargo) == 8, ( "each standard and explicit debug Cargo invocation must select one fragment" ) assert all( @@ -212,6 +218,28 @@ def test_linux_debug_routes_select_the_fragment_and_injected_cargo() -> None: ) +def test_rust_doctests_are_a_separate_debug_routed_gate() -> None: + """Keep doctests covered when nextest owns ordinary Rust tests.""" + output = _dry_run("test-rust", variables={"DEV_FAST_HOST_IS_LINUX": "yes"}) + doctest = next( + line + for line in _routing_lines(output) + if "test --workspace --doc --all-features" in line + ) + _assert_required_tokens( + doctest, + ( + "probe-cargo --config ../tools/dev-fast/config.toml", + 'RUSTFLAGS="-D warnings', + 'RUSTDOCFLAGS="--cfg docsrs -D warnings -Zunstable-options', + "--display-doctest-warnings", + "--doctest-build-arg=-D --doctest-build-arg=warnings", + "-Clink-arg=-fuse-ld=mold", + ), + "the Linux doctest route", + ) + + @pytest.mark.parametrize( "flags", [ diff --git a/cuprum/unittests/test_doctest_warning_contract.py b/cuprum/unittests/test_doctest_warning_contract.py new file mode 100644 index 000000000..b8f96ae41 --- /dev/null +++ b/cuprum/unittests/test_doctest_warning_contract.py @@ -0,0 +1,108 @@ +"""Prove the Linux doctest route fails when a doctest emits a warning.""" + +from __future__ import annotations + +import os +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] - this test executes a fixed pinned Cargo command. +import sys +import typing as typ + +import pytest + +if typ.TYPE_CHECKING: + from pathlib import Path + +DEV_FAST_TOOLCHAIN = "nightly-2026-08-23" +DOCTEST_RUSTDOC_FLAGS = ( + "--cfg docsrs -D warnings -Zunstable-options --display-doctest-warnings " + "--doctest-build-arg=-D --doctest-build-arg=warnings" +) +WARNING_DOCTEST = """//! Warning-bearing doctest fixture. + +/// ``` +/// #[deprecated(note = "doctest warning contract")] +/// fn legacy() {} +/// legacy(); +/// ``` +pub struct WarningFixture; +""" + + +def _run_doctests( + crate: Path, cargo: str, rustdoc_flags: str | None +) -> subprocess.CompletedProcess[str]: + """Run the pinned nightly with the standard backend and stated Rustdoc flags.""" + environment: dict[str, str] = dict(os.environ) + environment.update({ + "CARGO_TARGET_DIR": str(crate / "target"), + "RUSTFLAGS": "-D warnings", + "RUSTUP_TOOLCHAIN": DEV_FAST_TOOLCHAIN, + }) + if rustdoc_flags is not None: + environment["RUSTDOCFLAGS"] = rustdoc_flags + else: + environment.pop("RUSTDOCFLAGS", None) + return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Cargo argv and pinned toolchain. + [ + cargo, + "--color", + "never", + "test", + "--doc", + "--manifest-path", + str(crate / "Cargo.toml"), + ], + capture_output=True, + check=False, + cwd=crate, + env=environment, + text=True, + ) + + +def _diagnostics(completed: subprocess.CompletedProcess[str]) -> str: + """Return every Cargo diagnostic stream for setup and failure assertions.""" + return completed.stdout + completed.stderr + + +@pytest.mark.skipif( + sys.platform != "linux", reason="the production doctest warning route is Linux-only" +) +def test_pinned_doctest_route_rejects_a_warning(tmp_path: Path) -> None: + """Only the corrected nightly Rustdoc route rejects a doctest warning.""" + crate = tmp_path / "warning-doctest" + source = crate / "src/lib.rs" + source.parent.mkdir(parents=True) + (crate / "Cargo.toml").write_text( + '[package]\nname = "warning-doctest"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + source.write_text(WARNING_DOCTEST, encoding="utf-8") + cargo = shutil.which("cargo") + assert cargo is not None, "the doctest contract requires Cargo on PATH" + former_route = _run_doctests(crate, cargo, rustdoc_flags=None) + former_diagnostics = _diagnostics(former_route) + assert former_route.returncode == 0, ( + "the former RUSTFLAGS-only route must pass the warning-bearing doctest:\n" + f"{former_diagnostics}" + ) + assert "test result: ok" in former_diagnostics, ( + "the former route must execute the doctest rather than only build setup" + ) + + corrected_route = _run_doctests(crate, cargo, DOCTEST_RUSTDOC_FLAGS) + corrected_diagnostics = _diagnostics(corrected_route) + assert corrected_route.returncode != 0, ( + "the corrected Rustdoc route must reject a doctest warning:\n" + f"{corrected_diagnostics}" + ) + assert "\x1b" not in corrected_diagnostics, ( + "the synthetic Cargo route must disable colour for deterministic diagnostics" + ) + assert "error: use of deprecated function" in corrected_diagnostics, ( + "the corrected route must fail on the doctest warning rather than setup" + ) + assert "-D deprecated" in corrected_diagnostics, ( + "rustdoc must deny the displayed doctest warning" + ) diff --git a/docs/adr-011-audited-rust-boundaries.md b/docs/adr-011-audited-rust-boundaries.md index c008de89e..fc8388482 100644 --- a/docs/adr-011-audited-rust-boundaries.md +++ b/docs/adr-011-audited-rust-boundaries.md @@ -117,9 +117,12 @@ focused tests (`/tmp/issue379-reader-validation-before.log` and representability and non-negativity; it does not establish resource validity, lifetime, or exclusive ownership. -Nine safe-crate unsafe-rejection probes, including a future target without a -crate-level attribute, are covered by the contract gate. The normal callback -compile regression passed with the expected `&W` versus `&mut W` diagnostic in +The contract derives every automatic and explicitly configured Cargo target +root, requiring each to carry `#![forbid(unsafe_code)]`; its future-target +negative cases cover binary, build-script, example, integration-test, and +benchmark roots. The current two safe roots receive six deliberate +unsafe-rejection probes. The normal callback compile regression passed with the +expected `&W` versus `&mut W` diagnostic in `/tmp/issue379-round25-trybuild-normal.log`; the final fault run passed its controls and detected all four mutations in `/tmp/issue379-round27-faults.log`. Hosted Windows and macOS runtime execution passed on 2026-09-15: the "Rust @@ -183,9 +186,27 @@ suppressing its owner drop. This avoids carrying the former extension-wide `std::fs` lint exclusion into either extracted crate; `cap-std` and its handle adapters are trusted dependencies, not verifier-proved implementations. -The safe crate also declares `unsafe_code = "forbid"` in Cargo's lint table, so -future automatically discovered test, example, and benchmark targets inherit -the prohibition. Cargo cannot override an individual workspace-inherited lint; -the safe manifest therefore carries the workspace lint tables plus this one -addition. The boundary-contract gate requires exact policy parity, preventing -that required duplication from weakening other lints or drifting silently. +Cargo cannot override an individual workspace-inherited lint. The safe crate +therefore keeps its `#![forbid(unsafe_code)]` source-level prohibition while +all three members inherit the shared workspace tables. The boundary-contract +gate checks both the common manifest inheritance and every discovered safe +target root, including future automatic Cargo targets. + +## Addendum (2026-09-20): Lint baseline and target discovery + +The workspace now owns the common Rust, Clippy, and Rustdoc lint baseline in +`rust/Cargo.toml`; every member inherits it. `rust/clippy.toml` records the +reviewed complexity thresholds and the environment-access injection policy. The +approved unsafe boundary remains deliberately narrower: the syscall and FFI +crates cannot use a workspace-wide `unsafe_code` prohibition, while every +effective `cuprum-streams` target root must declare `#![forbid(unsafe_code)]`. + +The contract implementation evolved from a fixed target list to loading the +safe crate's manifest and effective Cargo roots. It honours `autolib`, +`autobins`, `autoexamples`, `autotests`, `autobenches`, and `build = false`, +while retaining explicit `lib`, `bin`, `example`, `test`, `bench`, and custom +build-script paths. Filesystem access, UTF-8 decoding, and TOML parsing fail +closed with the affected path; pure discovery and policy evaluation remain +separately testable. The Linux debug doctest gate uses the pinned dev-fast +nightly rustdoc route to display and deny doctest-body warnings. Stable Rust +1.85.0 remains the published MSRV and retains its separate verification route. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2ea0b5c34..7d1bd869f 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -3343,15 +3343,46 @@ Run the normal project test gate from the repository root: make test ``` -`make test` runs the Python pytest batches before the crate tests through -`cargo nextest`, including proptest cases compiled under `#[cfg(test)]`. Run -the complete Rust lint and formatting gates before committing Rust changes: +`make test` runs the Python pytest batches before the crate tests. The +`test-rust` target uses `cargo nextest run` when `cargo-nextest` is available +and otherwise falls back to `cargo test`, including proptest cases compiled +under `#[cfg(test)]`. It then runs Cargo doctests separately because Nextest +does not execute them. Run the complete Rust lint and formatting gates before +committing Rust changes: ```bash make check-fmt make lint ``` +### Rust lint baseline + +`rust/Cargo.toml` is the authoritative shared Rust lint table and every +workspace member declares `[lints] workspace = true`. `rust/clippy.toml` holds +the shared thresholds: cognitive complexity 9, four arguments, 70 lines, and +four nesting levels. New workspace members must inherit these tables rather +than replicate or weaken them. + +The baseline follows Concordat revision +`cf4333c3f38b55bf3d4d5d2663e2147e2da1cec8` with local extensions for +`missing_assert_message` and environment-method injection. `std::env` readers +and mutators are disallowed in favour of injected environment or +working-directory seams. Fix findings at source; `#[allow]` is not permitted, +and a reasoned `#[expect]` is reserved for macro-expansion artefacts or genuine +floating-point arithmetic. + +The workspace cannot forbid unsafe code globally: `cuprum-native-io` and +`cuprum-rust` are the audited syscall and FFI boundaries. `cuprum-streams` +keeps its explicit crate-level prohibition, which the boundary contract checks. +The +[ADR-011 addendum](adr-011-audited-rust-boundaries.md#addendum-2026-09-20-lint-baseline-and-target-discovery) +records why that source-level contract complements the shared lint tables. The +The Rust 1.85.0 maintenance toolchain provides `clippy` and `rust-analyzer`; +`RUSTFMT_TOOLCHAIN=nightly-2026-05-28` provides `rustfmt`. Linux debug Rustdoc, +Clippy, and doctests use the separately pinned dev-fast configuration, while +MSRV, coverage, release, verification, and Whitaker retain their prescribed +routes. + ### Rustfmt fixture compatibility The formatter maintenance pin is `nightly-2026-05-28`; Continuous Integration @@ -4068,7 +4099,7 @@ checks the members that result. `uv build --sdist` archive must retain: - the workspace manifests and pins — `rust/Cargo.toml`, `rust/Cargo.lock`, - `rust/rust-toolchain.toml`, and `rust/dylint.toml`; + `rust/clippy.toml`, `rust/rust-toolchain.toml`, and `rust/dylint.toml`; - each crate manifest — `rust/cuprum-rust/Cargo.toml`, `rust/cuprum-streams/Cargo.toml`, and `rust/cuprum-native-io/Cargo.toml`; - all three crate source trees, including each crate's `src/` and the @@ -4078,9 +4109,9 @@ Cargo build output is excluded: `target` never appears in an archive, so an sdist carries sources rather than a multi-gigabyte build cache. Maturin builds its archive from the same workspace, and the -`[tool.maturin].include` entries carry the two files it would not otherwise -copy — `rust/rust-toolchain.toml` and `rust/dylint.toml`, both restricted with -`format = "sdist"` so they never enter a wheel. +`[tool.maturin].include` entries carry the three files it would not otherwise +copy — `rust/clippy.toml`, `rust/rust-toolchain.toml`, and `rust/dylint.toml`, +all restricted with `format = "sdist"` so they never enter a wheel. `tests/test_native_sdist.py` verifies both frontends, and it is deliberately not a unit test: each case builds a genuine archive with `uv build --sdist` or diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 1e47a1853..9483e7451 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -35,6 +35,7 @@ _Figure 1: Simplified repository tree for contributor orientation._ | `docs/` | User, maintainer, design, decision, roadmap, and reference documentation. | | `docs/execplans/` | Durable execution plans for non-trivial implementation work. | | `rust/` | Cargo workspace for Rust extension code and Rust-specific build/test targets. | +| `rust/clippy.toml` | Shared Clippy thresholds and environment-access policy used by every workspace member. | | `rust/cuprum-rust/` | Thin PyO3/maturin integration crate for the optional native backend; raw Python resource hand-off lives here. | | `rust/cuprum-streams/` | Safe stream orchestration, pump policy, state machine, errors, and checked UTF-8 decoding; unsafe Rust is forbidden. | | `rust/cuprum-native-io/` | Audited native descriptor/handle ownership, single-call I/O, progress kernels, and platform test fixtures. | diff --git a/docs/rust-boundary-verification.md b/docs/rust-boundary-verification.md index 89e3c5f3f..a54b1a996 100644 --- a/docs/rust-boundary-verification.md +++ b/docs/rust-boundary-verification.md @@ -170,29 +170,40 @@ do not prove PyO3 internals or the CPython ABI. Table 3. Verification evidence by boundary. -Round 35 integrated local checks are green: the Python suite recorded 1,568 -passes and one skip in `/tmp/issue379-round35-test.log`, the Rust suite -recorded 115/115 tests with no skips, and the extension-required suite recorded -80 passes and one skip. The nine unsafe-code compiler probes, Windows -cross-Clippy, and development build also passed. The cached Kani setup check -had already passed in Round 33, which remains retained in the preceding -reports. Hosted Windows/macOS runtime execution passed on 2026-09-15: the -`Rust boundary verification` run 35004943625 succeeded with its -`Native contracts (windows-2022)`, `Native contracts (macos-latest)`, and -`Native contracts (ubuntu-latest)` jobs, and the `CI` run 35004943782 succeeded -with its `Extension-gated tests (Windows Python/Rust boundary)` and +Historical Round 35 integrated local checks are green: the Python suite +recorded 1,568 passes and one skip in `/tmp/issue379-round35-test.log`, the +Rust suite recorded 115/115 tests with no skips, and the extension-required +suite recorded 80 passes and one skip. That historical run included nine +unsafe-code compiler probes, Windows cross-Clippy, and the development build. +The cached Kani setup check had already passed in Round 33, which remains +retained in the preceding reports. Hosted Windows/macOS runtime execution +passed on 2026-09-15: the `Rust boundary verification` run 35004943625 +succeeded with its `Native contracts (windows-2022)`, +`Native contracts (macos-latest)`, and `Native contracts (ubuntu-latest)` jobs, +and the `CI` run 35004943782 succeeded with its +`Extension-gated tests (Windows Python/Rust boundary)` and `Extension-gated tests (Python/Rust boundary)` jobs. The boundary workflow's `extended` Kani/Miri job is skipped on pull requests by design, so these hosted runs add no Kani/Miri coverage. The second CodeRabbit review remains pending; the first review passed with 0 findings. -| Boundary and production surface | Property | Selected tool and executed subject | Assumptions and bounds | Native or integration evidence | Status and limitation | -| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cuprum-native-io` borrowed and owned Unix/Windows handles | Borrowed readers remain caller-owned; transferred writers close once on normal, error, and real unwind; no released/reused descriptor is touched. | Kani exercises the production `with_owned_writer` drop counter where Verus cannot represent OS handles or destructors. The direct Verus resource assessment is recorded below. | Valid-live-resource, unique-transfer, and no-concurrent-close obligations remain caller contracts. Kani's symbolic exit is bounded and has coverage witnesses. The writer callback receives an immutable reference, so it cannot replace or move the owned resource out of the scope. | Round 35 Rust checks covered 115/115 tests with no skips. Hosted Windows/macOS runtime execution was green in CI. | Round 27 Kani passed 7 native harnesses; Miri passed 13 tests with 0 ignored; the fault harness detected all 4 mutations. | -| `cuprum-native-io` read/write kernels | Buffers expose only initialized in-bounds bytes; short reads/writes, `EINTR`, zero progress, and checked byte accounting preserve valid state. | Verus verifies the exact production `progress.rs` bodies rendered by `scripts/render_boundary_proofs.py`; Kani checks the production progress kernels and ownership seam. | `checked_progress` rejects overflow and oversized lengths; `record_write_progress` commits tail/count only after both checks. Aggregate pump/consume tracing counts retain saturating-add semantics, so they are not exact beyond `u64::MAX`. | Round 35 Rust checks covered 115/115 tests with no skips. Hosted Windows/macOS runtime execution was green in CI. | Verus passed 2 functions with 0 errors; Kani/Miri and native tests remain scoped as recorded above. Neither verifier proves syscall effects. | -| Linux `splice` adapter and fallback policy | Unsupported descriptors fall back; partial/interrupted transfers and broken-pipe drain preserve bytes and termination. | Kani/Verus cover the typed decision, progress, and accumulation seams; the kernel call itself is external. Miri covers applicable native crate paths but excludes unshimmed `libc::splice`. | Syscall return/error guarantees, symbolic chunk and trace bounds, and non-vacuity witnesses are explicit assumptions or bounds. | Round 35 local Rust and native checks passed; hosted Windows/macOS runtime execution was green in CI. | The syscall is not directly verified or Miri-interpreted because `libc::splice` has no supported shim. Round 27 Miri passed 13 tests with 0 ignored. | -| `cuprum-streams` safe policy and decoder | Safe crate and all test/helper targets reject unsafe Rust; state machine never writes after closure, stops on EOF, and checked UTF-8 replacement preserves valid prefixes. | Compiler contract checks the copied safe crate and compile tests. Kani covers pure pump policy; checked UTF-8 no longer contains `from_utf8_unchecked`. | No raw resource integers or pointers enter the safe crate; lifetime-bound typed native handles do enter through `AsStream`. Aggregate tracing counts retain saturating-add semantics; per-chunk checked progress is verified separately. | Round 35 Rust checks covered 115/115 tests with no skips; nine compiler probes passed. Hosted Windows/macOS runtime execution was green in CI. | Safe-policy/decoder Kani passed 11 harnesses. | -| `cuprum-rust` PyO3 integration | Generated wrappers expose stable signatures, map errors, release the GIL safely, and preserve the native resource contract. | PyO3 compile UI tests and Python integration tests. Verus/Miri exclude interpreter-generated ABI code; typed Rust seams are verified separately. | PyO3/CPython ABI, `Send`, interpreter lifecycle, and Python transport hand-off are trusted assumptions. | Round 35 Python checks recorded 1,568 passes and one skip; the extension-required suite recorded 80 passes and one skip. | Local integration checks are green; Python lifetime establishment remains outside this evidence. Hosted Windows/macOS runs pass. The first CodeRabbit review passed with 0 findings; the second review remains pending. | +The current boundary contract is distinct from Round 35's historical nine-probe +run. It discovers all automatic and explicitly configured Cargo target roots, +requiring a source-level unsafe prohibition on each. Its current measured run +found the library and compile-contract target, compiled six unsafe probes, and +separately rejected unannotated future binary, build-script, example, +integration-test, and benchmark targets. +[ADR-011's 2026-09-20 addendum](adr-011-audited-rust-boundaries.md#addendum-2026-09-20-lint-baseline-and-target-discovery) +records the implementation evolution, including Cargo's automatic-target +switches and the I/O boundary around discovery. + +| Boundary and production surface | Property | Selected tool and executed subject | Assumptions and bounds | Native or integration evidence | Status and limitation | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cuprum-native-io` borrowed and owned Unix/Windows handles | Borrowed readers remain caller-owned; transferred writers close once on normal, error, and real unwind; no released/reused descriptor is touched. | Kani exercises the production `with_owned_writer` drop counter where Verus cannot represent OS handles or destructors. The direct Verus resource assessment is recorded below. | Valid-live-resource, unique-transfer, and no-concurrent-close obligations remain caller contracts. Kani's symbolic exit is bounded and has coverage witnesses. The writer callback receives an immutable reference, so it cannot replace or move the owned resource out of the scope. | Round 35 Rust checks covered 115/115 tests with no skips. Hosted Windows/macOS runtime execution was green in CI. | Round 27 Kani passed 7 native harnesses; Miri passed 13 tests with 0 ignored; the fault harness detected all 4 mutations. | +| `cuprum-native-io` read/write kernels | Buffers expose only initialized in-bounds bytes; short reads/writes, `EINTR`, zero progress, and checked byte accounting preserve valid state. | Verus verifies the exact production `progress.rs` bodies rendered by `scripts/render_boundary_proofs.py`; Kani checks the production progress kernels and ownership seam. | `checked_progress` rejects overflow and oversized lengths; `record_write_progress` commits tail/count only after both checks. Aggregate pump/consume tracing counts retain saturating-add semantics, so they are not exact beyond `u64::MAX`. | Round 35 Rust checks covered 115/115 tests with no skips. Hosted Windows/macOS runtime execution was green in CI. | Verus passed 2 functions with 0 errors; Kani/Miri and native tests remain scoped as recorded above. Neither verifier proves syscall effects. | +| Linux `splice` adapter and fallback policy | Unsupported descriptors fall back; partial/interrupted transfers and broken-pipe drain preserve bytes and termination. | Kani/Verus cover the typed decision, progress, and accumulation seams; the kernel call itself is external. Miri covers applicable native crate paths but excludes unshimmed `libc::splice`. | Syscall return/error guarantees, symbolic chunk and trace bounds, and non-vacuity witnesses are explicit assumptions or bounds. | Round 35 local Rust and native checks passed; hosted Windows/macOS runtime execution was green in CI. | The syscall is not directly verified or Miri-interpreted because `libc::splice` has no supported shim. Round 27 Miri passed 13 tests with 0 ignored. | +| `cuprum-streams` safe policy and decoder | Every automatic or explicit Cargo target root rejects unsafe Rust; state machine never writes after closure, stops on EOF, and checked UTF-8 replacement preserves valid prefixes. | Compiler contract discovers safe roots, compiles deliberate probes against current roots, and rejects a future unannotated root. Kani covers pure pump policy; checked UTF-8 no longer contains `from_utf8_unchecked`. | No raw resource integers or pointers enter the safe crate; lifetime-bound typed native handles do enter through `AsStream`. Aggregate tracing counts retain saturating-add semantics; per-chunk checked progress is verified separately. | Historical Round 35 Rust checks covered 115/115 tests with no skips. The later current contract run found two roots, compiled six probes, and rejected all dynamic target cases. | Safe-policy/decoder Kani passed 11 harnesses. | +| `cuprum-rust` PyO3 integration | Generated wrappers expose stable signatures, map errors, release the GIL safely, and preserve the native resource contract. | PyO3 compile UI tests and Python integration tests. Verus/Miri exclude interpreter-generated ABI code; typed Rust seams are verified separately. | PyO3/CPython ABI, `Send`, interpreter lifecycle, and Python transport hand-off are trusted assumptions. | Round 35 Python checks recorded 1,568 passes and one skip; the extension-required suite recorded 80 passes and one skip. | Local integration checks are green; Python lifetime establishment remains outside this evidence. Hosted Windows/macOS runs pass. The first CodeRabbit review passed with 0 findings; the second review remains pending. | Round 22 also found a packaging boundary omission. A real `uv build --sdist` archive initially omitted the Rust workspace, and diff --git a/pyproject.toml b/pyproject.toml index 23341562e..84eba3ae3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -398,6 +398,7 @@ also_copy = [ "rust/cuprum-native-io/", "rust/Cargo.toml", "rust/Cargo.lock", + "rust/clippy.toml", "rust/dylint.toml", ] @@ -497,6 +498,7 @@ source-exclude = ["cuprum/unittests/**"] source-include = [ "rust/Cargo.toml", "rust/Cargo.lock", + "rust/clippy.toml", "rust/rust-toolchain.toml", "rust/dylint.toml", "rust/cuprum-rust/Cargo.toml", @@ -516,6 +518,7 @@ module-name = "cuprum._rust_backend_native" python-source = "." exclude = ["cuprum/unittests/**"] include = [ + { path = "rust/clippy.toml", format = "sdist" }, { path = "rust/rust-toolchain.toml", format = "sdist" }, { path = "rust/dylint.toml", format = "sdist" }, ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 12c5596fb..ba1fa9efa 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -15,8 +15,10 @@ allow_attributes = "deny" allow_attributes_without_reason = "deny" blanket_clippy_restriction_lints = "deny" cognitive_complexity = "deny" +disallowed_methods = "deny" needless_pass_by_value = "deny" implicit_hasher = "deny" +missing_assert_message = "deny" # 2. debugging leftovers dbg_macro = "deny" @@ -71,7 +73,15 @@ result_large_err = "deny" [workspace.lints.rust] missing_docs = "deny" +renamed_and_removed_lints = "deny" +unknown_lints = "deny" unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] } [workspace.lints.rustdoc] +bare_urls = "deny" +broken_intra_doc_links = "deny" +invalid_codeblock_attributes = "deny" +invalid_html_tags = "deny" missing_crate_level_docs = "deny" +private_intra_doc_links = "deny" +unescaped_backticks = "deny" diff --git a/rust/clippy.toml b/rust/clippy.toml new file mode 100644 index 000000000..57eb1ccf6 --- /dev/null +++ b/rust/clippy.toml @@ -0,0 +1,20 @@ +# Align with CodeScene's ceiling +cognitive-complexity-threshold = 9 # default is 25 +too-many-arguments-threshold = 4 # default is 7 +too-many-lines-threshold = 70 # default is 100 +excessive-nesting-threshold = 4 # default is off + +allow-expect-in-tests = true + +# The signed Netsuke baseline at 924cb215 requires explicit environment +# injection. The reasons appear in diagnostics, directing callers to the +# boundary that makes environment access testable. +disallowed-methods = [ + { path = "std::env::var", reason = "inject an environment reader" }, + { path = "std::env::var_os", reason = "inject an environment reader" }, + { path = "std::env::vars", reason = "inject an environment reader" }, + { path = "std::env::vars_os", reason = "inject an environment reader" }, + { path = "std::env::set_var", reason = "use a stub environment in tests" }, + { path = "std::env::remove_var", reason = "use a stub environment in tests" }, + { path = "std::env::set_current_dir", reason = "inject a base-directory seam; confine CWD changes to Command::current_dir" }, +] diff --git a/rust/cuprum-streams/Cargo.toml b/rust/cuprum-streams/Cargo.toml index 83250543c..6368c6750 100644 --- a/rust/cuprum-streams/Cargo.toml +++ b/rust/cuprum-streams/Cargo.toml @@ -23,75 +23,8 @@ rstest = "0.26.1" tempfile = "3.27.0" trybuild = { version = "1.0.116", features = ["diff"] } -# Cargo cannot override one lint while inheriting workspace lints. The -# boundary-contract gate requires these tables to match the workspace plus -# unsafe_code = "forbid", which applies to every current and future target. -[lints.clippy] -pedantic = { level = "warn", priority = -1 } - -# 1. hygiene -allow_attributes = "deny" -allow_attributes_without_reason = "deny" -blanket_clippy_restriction_lints = "deny" -cognitive_complexity = "deny" -needless_pass_by_value = "deny" -implicit_hasher = "deny" - -# 2. debugging leftovers -dbg_macro = "deny" -print_stdout = "deny" -print_stderr = "deny" - -# 3. panic-prone operations -unwrap_used = "deny" -expect_used = "deny" -indexing_slicing = "deny" -string_slice = "deny" -integer_division = "deny" -integer_division_remainder_used = "deny" -panic_in_result_fn = "deny" -unreachable = "deny" - -# 4. portability -host_endian_bytes = "deny" -little_endian_bytes = "deny" -big_endian_bytes = "deny" - -# 5. nursery idiom polish -let_underscore_must_use = "deny" -or_fun_call = "deny" -option_if_let_else = "deny" -self_named_module_files = "deny" -shadow_reuse = "deny" -shadow_same = "deny" -shadow_unrelated = "deny" -str_to_string = "deny" -string_lit_as_bytes = "deny" -try_err = "deny" -unneeded_field_pattern = "deny" -use_self = "deny" - -# 6. numerical foot-guns -float_arithmetic = "deny" -cast_possible_truncation = "deny" -cast_possible_wrap = "deny" -cast_precision_loss = "deny" -lossy_float_literal = "deny" - -# 7. API ergonomics -missing_const_for_fn = "deny" -must_use_candidate = "deny" -unused_async = "deny" - -# 8. Error handling -missing_panics_doc = "deny" -error_impl_error = "deny" -result_large_err = "deny" - -[lints.rust] -unsafe_code = "forbid" -missing_docs = "deny" -unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] } - -[lints.rustdoc] -missing_crate_level_docs = "deny" +# Safe-stream code retains `#![forbid(unsafe_code)]` in its crate root. The +# workspace intentionally does not impose that rule on the audited syscall and +# FFI boundaries, so every member can inherit one shared lint baseline here. +[lints] +workspace = true diff --git a/scripts/boundary_workspace.py b/scripts/boundary_workspace.py new file mode 100644 index 000000000..f897749d7 --- /dev/null +++ b/scripts/boundary_workspace.py @@ -0,0 +1,85 @@ +"""Copy the Rust boundary workspace without changing Cargo target topology.""" + +from __future__ import annotations + +import os +import shutil +import stat +import typing as typ +from pathlib import Path + +if typ.TYPE_CHECKING: + import collections.abc as cabc + + +def copy_workspace( + source: Path, destination: Path, source_targets: cabc.Iterable[Path] +) -> None: + """Copy one workspace, materializing safe source links before mutation.""" + source = _lexical_absolute(source) + targets = tuple(source_targets) + copied_targets = tuple( + _copied_target_path(source, destination, target) for target in targets + ) + copy_source_tree(source, destination) + for target, copied_target in zip(targets, copied_targets, strict=True): + if _is_symlink(target): + copied_target.unlink() + shutil.copyfile(target, copied_target) + _make_writable(copied_target) + + +def copy_source_tree(source: Path, destination: Path) -> None: + """Copy a workspace tree while ignoring only root Cargo build output.""" + source = _lexical_absolute(source) + + def ignore_root_build_output(directory: str, _names: list[str]) -> set[str]: + """Ignore Cargo build output only when copying the workspace root.""" + return {"target"} if Path(directory) == source else set() + + shutil.copytree(source, destination, ignore=ignore_root_build_output, symlinks=True) + + +def _lexical_absolute(path: Path) -> Path: + """Normalize a path without resolving its symlink topology.""" + return Path(os.path.normpath(Path.cwd() / path)) + + +def _copied_target_path(source: Path, destination: Path, target: Path) -> Path: + """Map a target into the copy only when no directory link can escape it.""" + lexical_target = _lexical_absolute(target) + try: + relative = lexical_target.relative_to(source) + except ValueError as error: + msg = f"source target escapes the Rust workspace: {target}" + raise ValueError(msg) from error + _reject_symlinked_parents(source, relative, target) + return destination / relative + + +def _reject_symlinked_parents(source: Path, relative: Path, target: Path) -> None: + """Reject target paths whose copied parent would still point outside the copy.""" + parent = source + for component in relative.parts[:-1]: + parent /= component + if _is_symlink(parent): + msg = f"source target has a symlinked parent directory: {target}" + raise ValueError(msg) + + +def _is_symlink(path: Path) -> bool: + """Inspect a source target without turning access errors into omissions.""" + try: + return stat.S_ISLNK(path.lstat().st_mode) + except OSError as error: + msg = f"cannot inspect source target {path}: {error}" + raise ValueError(msg) from error + + +def _make_writable(path: Path) -> None: + """Give the copied probe target an owner write bit without altering its source.""" + try: + path.chmod(stat.S_IMODE(path.stat().st_mode) | stat.S_IWUSR) + except OSError as error: + msg = f"cannot make copied source target writable {path}: {error}" + raise ValueError(msg) from error diff --git a/scripts/check_boundary_contract.py b/scripts/check_boundary_contract.py index 1867a9cf1..7d5245061 100644 --- a/scripts/check_boundary_contract.py +++ b/scripts/check_boundary_contract.py @@ -3,17 +3,18 @@ # requires-python = ">=3.13" # dependencies = ["cuprum==0.1.0"] # /// -"""Compile deliberate unsafe probes against a copy of the actual safe crate. +"""Check audited workspace lint inheritance and safe-crate unsafe boundaries. -The workspace copy avoids touching a developer's sources while proving that -the real library and integration-test targets reject unsafe code. Cargo keeps -its shared package cache. Only build output uses a separate target directory. +The isolated copy preserves Cargo target topology and leaves source untouched. """ from __future__ import annotations +import base64 import contextlib +import itertools import shutil +import stat import sys import tomllib import typing as typ @@ -23,6 +24,7 @@ import collections.abc as cabc from cuprum import Program, ProgramCatalogue, ProjectSettings, ScopeConfig, scoped, sh +from scripts.boundary_workspace import copy_workspace ROOT = Path(__file__).resolve().parent.parent BOUNDARIES = frozenset({"cuprum-rust", "cuprum-native-io"}) @@ -32,22 +34,30 @@ "pub unsafe fn unsafe_probe() {}", "unsafe trait Probe {} unsafe impl Probe for () {}", ) +AUTOMATIC_TARGET_ROOTS = (("autolib", "src/lib.rs"), ("autobins", "src/main.rs")) +AUTOMATIC_TARGET_DIRECTORIES = ( + ("autobins", "src/bin"), + ("autoexamples", "examples"), + ("autotests", "tests"), + ("autobenches", "benches"), +) +EXPLICIT_TARGET_TYPES = ("lib", "bin", "example", "test", "bench") def check_members(manifest: str) -> None: - """Require an explicit review before the approved crate set can grow. + """Require review before the approved crate set can grow.""" + _check_members_manifest(tomllib.loads(manifest)) - Parameters - ---------- - manifest : str - Workspace manifest text. - Raises - ------ - ValueError - If workspace membership differs from the audited set. - """ - members = tomllib.loads(manifest)["workspace"]["members"] +def _check_members_manifest(manifest: cabc.Mapping[str, object]) -> None: + """Validate audited workspace membership from parsed manifest data.""" + workspace = manifest.get("workspace") + members = workspace.get("members") if isinstance(workspace, dict) else None + if not isinstance(members, list) or not all( + isinstance(member, str) for member in members + ): + msg = "workspace membership differs from the audited boundary inventory" + raise ValueError(msg) if set(members) != BOUNDARIES | SAFE_CRATES or len(members) != len( BOUNDARIES | SAFE_CRATES ): @@ -55,27 +65,253 @@ def check_members(manifest: str) -> None: raise ValueError(msg) -def check_safe_policy(workspace_manifest: str, safe_manifest: str) -> None: - """Require workspace lint parity plus an unrelaxable safe-target prohibition. +def check_member_lint_inheritance(workspace: Path) -> None: + """Require each audited package to inherit the workspace lint baseline. Parameters ---------- - workspace_manifest : str - Workspace manifest containing the shared lint policy. - safe_manifest : str - Safe crate manifest, whose lint policy applies to every Cargo target. + workspace : Path + Root of the Rust workspace containing audited member manifests. + + Raises + ------ + ValueError + If an audited package replaces or weakens the shared policy locally. + """ + for member in BOUNDARIES | SAFE_CRATES: + manifest_path = workspace / member / "Cargo.toml" + manifest = _load_toml(manifest_path) + if manifest.get("lints") != {"workspace": True}: + msg = f"{member} must inherit the workspace lint baseline" + raise ValueError(msg) + + +def safe_target_roots(workspace: Path) -> tuple[Path, ...]: + """Return effective automatic and explicit Cargo target roots. + + Explicit tables replace matching conventional roots, as Cargo metadata does. + + Returns + ------- + tuple[Path, ...] + Effective crate-root source paths. + """ + crate = workspace / "cuprum-streams" + manifest = _load_toml(crate / "Cargo.toml") + package = manifest.get("package") + package = package if isinstance(package, dict) else {} + automatic = _load_automatic_target_roots(crate, package) + explicit = _explicit_target_roots(crate, manifest, package) + overrides = _automatic_roots_overridden_by_explicit_targets( + crate, manifest, package, automatic + ) + active_automatic = (path for path in automatic if path not in overrides) + return discover_safe_target_roots(active_automatic, explicit) + + +def discover_safe_target_roots( + automatic: cabc.Iterable[Path], explicit: cabc.Iterable[Path] +) -> tuple[Path, ...]: + """Deduplicate automatic and explicit crate roots. + + Returns + ------- + tuple[Path, ...] + Sorted effective crate-root source paths. + """ + return tuple(sorted(set(itertools.chain(automatic, explicit)))) + + +def evaluate_safe_target_policy(sources: cabc.Mapping[Path, str]) -> tuple[Path, ...]: + """Require every loaded source to forbid unsafe code. + + Returns + ------- + tuple[Path, ...] + Validated crate-root source paths. Raises ------ ValueError - If the safe crate weakens or diverges from the workspace policy. + If no target root exists or a root lacks the required prohibition. """ - expected = tomllib.loads(workspace_manifest)["workspace"]["lints"] - expected["rust"]["unsafe_code"] = "forbid" - actual = tomllib.loads(safe_manifest)["lints"] - if actual != expected: - msg = "safe crate must retain workspace lints and forbid unsafe in all targets" + if not sources: + msg = "safe crate has no discoverable Cargo target roots" raise ValueError(msg) + for target, source in sources.items(): + if "#![forbid(unsafe_code)]" not in source: + msg = f"safe target must forbid unsafe code: {target}" + raise ValueError(msg) + return tuple(sources) + + +def _load_toml(path: Path) -> cabc.Mapping[str, object]: + """Read and parse a manifest while retaining its path on failure.""" + text = _read_text(path) + try: + manifest = tomllib.loads(text) + except tomllib.TOMLDecodeError as error: + msg = f"cannot parse TOML manifest {path}: {error}" + raise ValueError(msg) from error + return manifest + + +def _read_text(path: Path) -> str: + """Read UTF-8 text and fail closed when inspection is impossible.""" + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + msg = f"cannot read {path}: {error}" + raise ValueError(msg) from error + + +def _load_automatic_target_roots( + crate: Path, package: cabc.Mapping[str, object] +) -> tuple[Path, ...]: + """Load existing automatic Cargo target roots enabled by package switches.""" + direct = ( + crate / path + for switch, path in AUTOMATIC_TARGET_ROOTS + if package.get(switch) is not False + ) + directories = ( + crate / directory + for switch, directory in AUTOMATIC_TARGET_DIRECTORIES + if package.get(switch) is not False + ) + candidates = itertools.chain( + direct, + itertools.chain.from_iterable( + _load_directory_target_roots(path) for path in directories + ), + (crate / "build.rs",) if package.get("build", True) is True else (), + ) + return tuple(path for path in candidates if _is_mode(path, stat.S_ISREG)) + + +def _load_directory_target_roots(directory: Path) -> cabc.Iterator[Path]: + """Yield flat and nested conventional Cargo target roots.""" + for entry in _directory_entries(directory): + if entry.name.startswith("."): + continue + if entry.suffix == ".rs" and _is_mode(entry, stat.S_ISREG): + yield entry + continue + if _is_mode(entry, stat.S_ISLNK, follow=False): + continue + if not _is_mode(entry, stat.S_ISDIR): + continue + candidate = entry / "main.rs" + if _is_mode(candidate, stat.S_ISREG): + yield candidate + + +def _directory_entries(directory: Path) -> tuple[Path, ...]: + """List an automatic-target directory without masking access failures.""" + try: + return tuple(directory.iterdir()) + except (FileNotFoundError, NotADirectoryError): + return () + except OSError as error: + msg = f"cannot inspect automatic target directory {directory}: {error}" + raise ValueError(msg) from error + + +def _is_mode( + path: Path, check: cabc.Callable[[int], bool], *, follow: bool = True +) -> bool: + """Inspect one target path with a stat-mode predicate.""" + try: + return check(path.stat(follow_symlinks=follow).st_mode) + except FileNotFoundError: + return False + except OSError as error: + msg = f"cannot inspect target path {path}: {error}" + raise ValueError(msg) from error + + +def _automatic_roots_overridden_by_explicit_targets( + crate: Path, + manifest: cabc.Mapping[str, object], + package: cabc.Mapping[str, object], + automatic: cabc.Iterable[Path], +) -> frozenset[Path]: + """Identify automatic roots replaced by explicit Cargo target tables.""" + candidates = itertools.chain.from_iterable( + _inferred_target_roots(crate, target_type, target, package) + for target_type in EXPLICIT_TARGET_TYPES + for target in _target_definitions(manifest.get(target_type)) + ) + return frozenset(automatic).intersection(candidates) + + +def _explicit_target_roots( + crate: Path, + manifest: cabc.Mapping[str, object], + package: cabc.Mapping[str, object], +) -> cabc.Iterator[Path]: + """Yield source roots from explicit Cargo tables and build configuration.""" + target_roots = itertools.chain.from_iterable( + ( + (crate / path,) + if isinstance((path := target.get("path")), str) + else _inferred_target_roots(crate, target_type, target, package) + ) + for target_type in EXPLICIT_TARGET_TYPES + for target in _target_definitions(manifest.get(target_type)) + ) + build_script = package.get("build") + configured_build = (crate / build_script,) if isinstance(build_script, str) else () + return itertools.chain(target_roots, configured_build) + + +def _inferred_target_roots( + crate: Path, + target_type: str, + target: cabc.Mapping[str, object], + package: cabc.Mapping[str, object], +) -> tuple[Path, ...]: + """Return existing conventional paths Cargo infers for one target table.""" + match target_type: + case "lib": + candidates = (crate / "src/lib.rs",) + case "bin" | "example" | "test" | "bench": + name = target.get("name") + if not isinstance(name, str): + return () + directory = { + "bin": "src/bin", + "example": "examples", + "test": "tests", + "bench": "benches", + }[target_type] + candidates = ( + crate / directory / f"{name}.rs", + crate / directory / name / "main.rs", + ) + if target_type == "bin" and name == package.get("name"): + candidates = (crate / "src/main.rs", *candidates) + case _: + return () + return tuple(path for path in candidates if _is_mode(path, stat.S_ISREG)) + + +def _target_definitions(value: object) -> tuple[cabc.Mapping[str, object], ...]: + """Normalize a Cargo target section into target definitions.""" + match value: + case dict() as target: + return (target,) + case list() as targets: + return tuple(target for target in targets if isinstance(target, dict)) + case _: + return () + + +def check_safe_policy(workspace: Path) -> tuple[Path, ...]: + """Require every effective safe target root to forbid unsafe source code.""" + targets = safe_target_roots(workspace) + sources = {target: _read_text(target) for target in targets} + return evaluate_safe_target_policy(sources) def _compile(workspace: Path) -> tuple[int, str]: @@ -102,20 +338,13 @@ def _compile(workspace: Path) -> tuple[int, str]: def main() -> None: """Check the allowlist and actual compiler rejection on isolated source copies.""" - manifest = (ROOT / "rust/Cargo.toml").read_text(encoding="utf-8") - check_members(manifest) - check_safe_policy( - manifest, - (ROOT / "rust/cuprum-streams/Cargo.toml").read_text(encoding="utf-8"), - ) + _check_members_manifest(_load_toml(ROOT / "rust/Cargo.toml")) + check_member_lint_inheritance(ROOT / "rust") + safe_targets = check_safe_policy(ROOT / "rust") workspace = ROOT / ".cache/boundary-contract/workspace" if workspace.exists(): shutil.rmtree(workspace) - shutil.copytree(ROOT / "rust", workspace, ignore=shutil.ignore_patterns("target")) - future_target = workspace / "cuprum-streams/tests/future_target.rs" - future_target.write_text( - "//! Probe automatic Cargo target lint inheritance.\n", encoding="utf-8" - ) + copy_workspace(ROOT / "rust", workspace, safe_targets) logs = ROOT / "rust/target/boundary-verification" logs.mkdir(parents=True, exist_ok=True) code, output = _compile(workspace) @@ -127,34 +356,40 @@ def main() -> None: file=sys.stderr, ) raise SystemExit(code) - targets = ("src/lib.rs", "tests/compile_tests.rs", "tests/future_target.rs") - for target in targets: - _check_target(workspace, workspace / "cuprum-streams" / target, logs) + for target in safe_targets: + _check_target(workspace, workspace / target.relative_to(ROOT / "rust"), logs) def _unsafe_was_forbidden(code: int, output: str) -> bool: - """Require compilation failure with an explicit unsafe-forbid diagnostic.""" + """Return whether a compiler result names the required unsafe prohibition.""" markers = ("forbid(unsafe_code)", "-F unsafe-code") return code != 0 and any(marker in output for marker in markers) @contextlib.contextmanager def _probe_appended(path: Path, probe: str) -> cabc.Iterator[None]: - """Temporarily append a probe to a source file, then restore it.""" - original = path.read_text(encoding="utf-8") - path.write_text(original + "\n" + probe + "\n", encoding="utf-8") + """Temporarily append a byte-preserving probe, then restore the source.""" + original = path.read_bytes() try: + path.write_bytes(original + b"\n" + probe.encode() + b"\n") yield finally: - path.write_text(original, encoding="utf-8") + path.write_bytes(original) + + +def _probe_log_name(workspace: Path, path: Path, index: int) -> str: + """Return a filesystem-safe, reversible workspace-relative probe log name.""" + relative = path.relative_to(workspace).as_posix() + encoded = base64.urlsafe_b64encode(relative.encode()).decode().rstrip("=") + return f"{path.stem}-{encoded}-unsafe-{index}.log" def _check_target(workspace: Path, path: Path, logs: Path) -> None: - """Probe one actual target and restore it even if compiler checking fails.""" + """Probe one target and archive each compiler result.""" for index, probe in enumerate(PROBES): with _probe_appended(path, probe): code, output = _compile(workspace) - name = f"{path.stem}-unsafe-{index}.log" + name = _probe_log_name(workspace, path, index) (logs / name).write_text(output, encoding="utf-8") if not _unsafe_was_forbidden(code, output): msg = f"safe target did not reject unsafe probe: {name}" diff --git a/scripts/tests/boundary_harness_support.py b/scripts/tests/boundary_harness_support.py index 6c30e683e..5606951d5 100644 --- a/scripts/tests/boundary_harness_support.py +++ b/scripts/tests/boundary_harness_support.py @@ -8,16 +8,13 @@ from __future__ import annotations +import json import shutil -import typing as typ +import subprocess # ruff: ignore[suspicious-subprocess-import] - fixed Cargo metadata argv. +from pathlib import Path from scripts import check_boundary_contract - -if typ.TYPE_CHECKING: - from pathlib import Path - -# Build output is the only part of the workspace a harness must never copy. -IGNORED = shutil.ignore_patterns("target") +from scripts.boundary_workspace import copy_source_tree def copy_boundary_repository(tmp_path: Path) -> Path: @@ -28,8 +25,30 @@ def copy_boundary_repository(tmp_path: Path) -> Path: Path Temporary root laid out like the repository, minus any build output. """ - shutil.copytree( - check_boundary_contract.ROOT / "rust", tmp_path / "rust", ignore=IGNORED - ) + copy_source_tree(check_boundary_contract.ROOT / "rust", tmp_path / "rust") shutil.copytree(check_boundary_contract.ROOT / "tools", tmp_path / "tools") return tmp_path + + +def cargo_target_roots(crate: Path) -> tuple[Path, ...]: + """Return Cargo metadata source roots for the synthetic safe package.""" + cargo = shutil.which("cargo") + if cargo is None: + msg = "Cargo is required for the metadata contract" + raise RuntimeError(msg) + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Cargo metadata argv runs in the isolated fixture. + [cargo, "metadata", "--no-deps", "--format-version=1"], + capture_output=True, + check=False, + cwd=crate, + text=True, + ) + if completed.returncode != 0: + raise RuntimeError(completed.stderr) + metadata = json.loads(completed.stdout) + package = next( + package + for package in metadata["packages"] + if package["name"] == "cuprum-streams" + ) + return tuple(sorted(Path(target["src_path"]) for target in package["targets"])) diff --git a/scripts/tests/test_boundary_contract.py b/scripts/tests/test_boundary_contract.py index b15b7df45..81eb2165e 100644 --- a/scripts/tests/test_boundary_contract.py +++ b/scripts/tests/test_boundary_contract.py @@ -7,19 +7,36 @@ from __future__ import annotations +import base64 from pathlib import Path import pytest from scripts import check_boundary_contract as contract -from scripts.check_boundary_contract import check_members, check_safe_policy -from scripts.tests.boundary_harness_support import copy_boundary_repository +from scripts.check_boundary_contract import ( + check_member_lint_inheritance, + check_members, + check_safe_policy, + safe_target_roots, +) +from scripts.tests.boundary_harness_support import ( + cargo_target_roots, + copy_boundary_repository, +) # The one diagnostic string that distinguishes a rejected probe from a compile # that failed for some unrelated reason. UNSAFE_MARKERS = ("forbid(unsafe_code)", "-F unsafe-code") -TARGETS = ("src/lib.rs", "tests/compile_tests.rs", "tests/future_target.rs") +TARGETS = ("src/lib.rs", "tests/compile_tests.rs") +FUTURE_TARGETS = ( + "src/main.rs", + "src/bin/future_target.rs", + "build.rs", + "examples/future_target.rs", + "tests/future_target.rs", + "benches/future_target.rs", +) class _RecordedCompile: @@ -78,34 +95,124 @@ def test_audited_members_are_accepted() -> None: ) -@pytest.mark.parametrize("replacement", ["deny", "allow"]) -def test_safe_targets_cannot_weaken_unsafe_policy(replacement: str) -> None: - """A target-wide deny can be locally relaxed, so only forbid is accepted.""" +def test_all_audited_members_inherit_the_workspace_lint_baseline() -> None: + """Every package must use the root tables without a local replacement.""" root = Path(__file__).resolve().parents[2] / "rust" - workspace = (root / "Cargo.toml").read_text(encoding="utf-8") - safe = (root / "cuprum-streams/Cargo.toml").read_text(encoding="utf-8") - check_safe_policy(workspace, safe) - weakened = safe.replace('unsafe_code = "forbid"', f'unsafe_code = "{replacement}"') - with pytest.raises(ValueError, match="forbid unsafe in all targets"): - check_safe_policy(workspace, weakened) + check_member_lint_inheritance(root) + + +@pytest.mark.parametrize( + "member", ["cuprum-rust", "cuprum-native-io", "cuprum-streams"] +) +def test_member_lint_inheritance_rejects_a_local_override( + member: str, tmp_path: Path +) -> None: + """No audited member may opt out of the shared lint baseline.""" + root = copy_boundary_repository(tmp_path) / "rust" + manifest = root / member / "Cargo.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + "[lints]\nworkspace = true", "[lints]\nworkspace = false", 1 + ), + encoding="utf-8", + ) + with pytest.raises(ValueError, match=f"{member} must inherit"): + check_member_lint_inheritance(root) + + +def test_safe_target_roots_cover_every_current_automatic_target(tmp_path: Path) -> None: + """The target scan includes the library and compile-contract integration test.""" + root = copy_boundary_repository(tmp_path) / "rust" + actual = tuple( + path.relative_to(root / "cuprum-streams").as_posix() + for path in safe_target_roots(root) + ) + assert actual == TARGETS, "the safe-target scanner must cover every Cargo root" + + +@pytest.mark.parametrize("target_type", ["lib", "bin", "example", "test", "bench"]) +def test_safe_target_roots_cover_explicit_cargo_target_tables( + target_type: str, tmp_path: Path +) -> None: + """Every explicit Cargo target type remains inside the safe boundary.""" + root = copy_boundary_repository(tmp_path) / "rust" + crate = root / "cuprum-streams" + manifest = crate / "Cargo.toml" + heading = f"[{target_type}]" if target_type == "lib" else f"[[{target_type}]]" + relative_path = f"tools/{target_type}_target.rs" + manifest.write_text( + manifest.read_text(encoding="utf-8") + + f'\n{heading}\npath = "{relative_path}"\n', + encoding="utf-8", + ) + target = crate / relative_path + target.parent.mkdir() + target.write_text( + "//! Explicit safe target.\n#![forbid(unsafe_code)]\n", + encoding="utf-8", + ) + + roots = safe_target_roots(root) + + assert target in roots, f"the explicit {target_type} target must be checked" + + +@pytest.mark.parametrize("target", FUTURE_TARGETS) +def test_new_safe_target_without_an_unsafe_prohibition_is_rejected( + target: str, tmp_path: Path +) -> None: + """A future automatic target cannot weaken the safe-crate boundary.""" + root = copy_boundary_repository(tmp_path) / "rust" + check_safe_policy(root) + future_target = root / "cuprum-streams" / target + future_target.parent.mkdir(parents=True, exist_ok=True) + future_target.write_text( + "//! Deliberately incomplete safety contract.\n", encoding="utf-8" + ) + with pytest.raises(ValueError, match="safe target must forbid unsafe code"): + check_safe_policy(root) + + +def test_configured_build_script_without_an_unsafe_prohibition_is_rejected( + tmp_path: Path, +) -> None: + """An explicit build-script path is also an unsafe-policy target root.""" + root = copy_boundary_repository(tmp_path) / "rust" + manifest = root / "cuprum-streams/Cargo.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + "publish = false", 'publish = false\nbuild = "tools/build.rs"' + ), + encoding="utf-8", + ) + target = root / "cuprum-streams/tools/build.rs" + target.parent.mkdir() + target.write_text( + "//! Deliberately incomplete safety contract.\n", encoding="utf-8" + ) + with pytest.raises(ValueError, match="safe target must forbid unsafe code"): + check_safe_policy(root) def test_main_probes_every_target_with_every_unsafe_form( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The baseline compiles, then each target is probed with all three forms.""" + """The baseline compiles, then each explicit safe root receives every probe.""" root, compiler = _drive_contract(tmp_path, monkeypatch) for probe in contract.PROBES: assert sum(probe in text for text in compiler.probes) == len(TARGETS), ( f"the {probe!r} probe must be compiled against every target" ) + workspace = root / ".cache/boundary-contract/workspace" logs = root / "rust/target/boundary-verification" assert (logs / "safe-positive.log").read_text(encoding="utf-8") == ( "finished checking" ), "the baseline result must be archived" expected = [ - f"{Path(target).stem}-unsafe-{index}.log" + contract._probe_log_name( + workspace, workspace / "cuprum-streams" / target, index + ) for target in TARGETS for index in range(3) ] @@ -115,6 +222,92 @@ def test_main_probes_every_target_with_every_unsafe_form( ]), "every probe result must be archived under its target and form" +def test_main_preserves_automatic_directory_symlinks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The isolated probe workspace retains Cargo's directory-symlink topology.""" + root = copy_boundary_repository(tmp_path) + crate = root / "rust/cuprum-streams" + shared = crate / "shared/main.rs" + shared.parent.mkdir() + shared.write_text("//! Non-target source.\n", encoding="utf-8") + try: + (crate / "tests/shared").symlink_to("../shared", target_is_directory=True) + (crate / "tests/broken").symlink_to("../missing", target_is_directory=True) + except OSError as error: + pytest.skip(f"the platform cannot create the symlink fixture: {error}") + before = cargo_target_roots(crate) + assert {path.relative_to(crate) for path in before} == { + Path(path) for path in TARGETS + }, "Cargo must omit directory symlink targets before the copy" + compiler = _RecordedCompile() + monkeypatch.setattr(contract, "ROOT", root) + monkeypatch.setattr(contract, "_compile", compiler) + + contract.main() + + copied_crate = root / ".cache/boundary-contract/workspace/cuprum-streams" + after = cargo_target_roots(copied_crate) + assert {path.relative_to(copied_crate) for path in after} == { + Path(path) for path in TARGETS + }, "Cargo must omit directory symlink targets after the copy" + assert all((copied_crate / target).is_file() for target in TARGETS), ( + "the copied workspace must retain each intended target" + ) + assert (copied_crate / "tests/shared").is_symlink(), ( + "the copied workspace must preserve valid directory symlinks" + ) + assert (copied_crate / "tests/broken").is_symlink(), ( + "the copied workspace must preserve broken directory symlinks" + ) + assert len(compiler.probes) == 1 + len(TARGETS) * len(contract.PROBES), ( + "the copied topology must leave the probe target count unchanged" + ) + + +def test_same_basename_target_logs_are_unique_and_traceable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Separate Cargo targets retain each unsafe-form result without overwriting.""" + workspace = tmp_path / "workspace" + logs = tmp_path / "logs" + logs.mkdir() + targets = ( + workspace / "cuprum-streams/examples/client.rs", + workspace / "cuprum-streams/tests/client.rs", + ) + for target in targets: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("#![forbid(unsafe_code)]" + chr(10), encoding="utf-8") + monkeypatch.setattr( + contract, + "_compile", + lambda _workspace: (1, "error: forbid(unsafe_code)"), + ) + + for target in targets: + contract._check_target(workspace, target, logs) + + expected = { + contract._probe_log_name(workspace, target, index) + for target in targets + for index in range(len(contract.PROBES)) + } + assert {path.name for path in logs.glob("*.log")} == expected, ( + "each same-basename target and unsafe form must retain its own log" + ) + assert len(expected) == len(targets) * len(contract.PROBES), ( + "the expected log names must be distinct for every target and probe" + ) + for target in targets: + name = contract._probe_log_name(workspace, target, 0) + encoded = name.removeprefix("client-").removesuffix("-unsafe-0.log") + padding = "=" * (-len(encoded) % 4) + assert base64.urlsafe_b64decode(encoded + padding).decode() == ( + target.relative_to(workspace).as_posix() + ), "the encoded log name must identify its workspace-relative target" + + def test_every_probed_target_is_restored_afterwards( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/scripts/tests/test_boundary_target_discovery.py b/scripts/tests/test_boundary_target_discovery.py new file mode 100644 index 000000000..905652a28 --- /dev/null +++ b/scripts/tests/test_boundary_target_discovery.py @@ -0,0 +1,382 @@ +"""Exercise safe Cargo target discovery without compiling a fixture workspace.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from scripts import check_boundary_contract as contract +from scripts.check_boundary_contract import ( + check_safe_policy, + discover_safe_target_roots, + evaluate_safe_target_policy, + safe_target_roots, +) +from scripts.tests.boundary_harness_support import ( + cargo_target_roots, + copy_boundary_repository, +) + +HIDDEN_AUTOMATIC_TARGETS = ( + ("src/bin/.scratch.rs", "src/bin/visible.rs"), + ("src/bin/.scratch/main.rs", "src/bin/visible/main.rs"), +) +AUTO_DISABLED_TARGETS = ( + ("autolib", "src/lib.rs"), + ("autobins", "src/main.rs"), + ("autobins", "src/bin/flat.rs"), + ("autobins", "src/bin/nested/main.rs"), + ("autoexamples", "examples/example.rs"), + ("autotests", "tests/integration.rs"), + ("autobenches", "benches/benchmark.rs"), +) +AUTOMATIC_PROPERTY_TARGETS = ("src/lib.rs", "src/bin/nested/main.rs") +EXPLICIT_PROPERTY_TARGETS = ("src/lib.rs", "tools/explicit.rs", "tools/build.rs") +OVERRIDING_EXPLICIT_TARGETS = ( + ("lib", None, "src/lib.rs", "tools/custom_lib.rs"), + ("bin", "cuprum-streams", "src/main.rs", "tools/custom_main.rs"), + ("bin", "client", "src/bin/client.rs", "tools/custom_client.rs"), + ("example", "client", "examples/client.rs", "tools/custom_example.rs"), + ("test", "client", "tests/client.rs", "tools/custom_test.rs"), + ("bench", "client", "benches/client.rs", "tools/custom_bench.rs"), +) +PATHLESS_EXPLICIT_TARGETS = ( + ("lib", None, "src/lib.rs"), + ("bin", "cuprum-streams", "src/main.rs"), + ("bin", "flat", "src/bin/flat.rs"), + ("bin", "nested", "src/bin/nested/main.rs"), + ("example", "flat", "examples/flat.rs"), + ("example", "nested", "examples/nested/main.rs"), + ("test", "flat", "tests/flat.rs"), + ("test", "nested", "tests/nested/main.rs"), + ("bench", "flat", "benches/flat.rs"), + ("bench", "nested", "benches/nested/main.rs"), +) +SAFE_SOURCE = "//! Safe target.\n#![forbid(unsafe_code)]\n" + + +def _write_target(crate: Path, relative_path: str, source: str = SAFE_SOURCE) -> Path: + """Create one synthetic Cargo target source and return its absolute path.""" + target = crate / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(source, encoding="utf-8") + return target + + +def _set_package_field(manifest: Path, field: str) -> None: + """Add one tested package field beside the existing package metadata.""" + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + "publish = false", f"publish = false\n{field}", 1 + ), + encoding="utf-8", + ) + + +def _append_explicit_target( + manifest: Path, target_type: str, name: str | None, source: str +) -> None: + """Declare one named Cargo target table with a custom source path.""" + table = "[lib]" if target_type == "lib" else f"[[{target_type}]]" + name_entry = "" if name is None else chr(10) + f'name = "{name}"' + content = manifest.read_text(encoding="utf-8") + manifest.write_text( + f'{content}{chr(10)}{table}{name_entry}{chr(10)}path = "{source}"{chr(10)}', + encoding="utf-8", + ) + + +def _append_pathless_target(manifest: Path, target_type: str, name: str | None) -> None: + """Declare a target table whose source path Cargo must infer.""" + table = "[lib]" if target_type == "lib" else f"[[{target_type}]]" + name_entry = "" if name is None else f'\nname = "{name}"' + manifest.write_text( + manifest.read_text(encoding="utf-8") + f"\n{table}{name_entry}\n", + encoding="utf-8", + ) + + +@pytest.mark.parametrize(("hidden", "visible"), HIDDEN_AUTOMATIC_TARGETS) +def test_hidden_automatic_targets_are_ignored( + hidden: str, visible: str, tmp_path: Path +) -> None: + """Cargo ignores hidden automatic targets but retains their visible siblings.""" + root = copy_boundary_repository(tmp_path) / "rust" + crate = root / "cuprum-streams" + hidden_path = _write_target(crate, hidden, "//! Hidden automatic root." + chr(10)) + visible_path = _write_target(crate, visible) + roots = safe_target_roots(root) + + assert hidden_path not in roots, "Cargo must ignore a hidden automatic target" + assert visible_path in roots, "Cargo must retain a visible automatic target" + assert roots == cargo_target_roots(crate), ( + "the scanner and Cargo metadata must agree about hidden target entries" + ) + check_safe_policy(root) + + +@pytest.mark.parametrize( + "nested_target", + [ + "src/bin/behaviour.rs/main.rs", + "examples/behaviour.rs/main.rs", + "tests/behaviour.rs/main.rs", + "benches/behaviour.rs/main.rs", + ], +) +def test_visible_support_directory_without_main_is_not_a_target( + nested_target: str, tmp_path: Path +) -> None: + """Only a visible nested main.rs is an automatic Cargo target.""" + root = copy_boundary_repository(tmp_path) / "rust" + crate = root / "cuprum-streams" + (crate / "tests/support").mkdir(parents=True) + support_file = crate / "tests/README.md" + support_file.write_text("test support", encoding="utf-8") + _write_target(crate, "shared/main.rs") + try: + (crate / "tests/shared").symlink_to("../shared", target_is_directory=True) + (crate / "tests/shared.rs").symlink_to("../shared/main.rs") + except OSError as error: + pytest.skip(f"the platform cannot create the symlink fixture: {error}") + roots = safe_target_roots(root) + assert crate / "tests/support/main.rs" not in roots, ( + "support/main.rs is not a target" + ) + assert support_file not in roots, "an ordinary support file is not a target" + assert crate / "tests/shared/main.rs" not in roots, ( + "directory symlink is not a target" + ) + assert crate / "tests/shared.rs" in roots, ( + "a direct source symlink must retain Cargo's target behaviour" + ) + assert roots == cargo_target_roots(crate), "scanner must match Cargo metadata" + check_safe_policy(root) + nested = _write_target(crate, nested_target, "//! Missing." + chr(10)) + assert nested in safe_target_roots(root), "nested main.rs remains a target" + with pytest.raises(ValueError, match="safe target must forbid unsafe code"): + check_safe_policy(root) + + +@pytest.mark.parametrize(("switch", "target"), AUTO_DISABLED_TARGETS) +def test_disabled_automatic_targets_are_not_checked( + switch: str, target: str, tmp_path: Path +) -> None: + """Cargo's package auto-target switches remove only their matching roots.""" + root = copy_boundary_repository(tmp_path) / "rust" + crate = root / "cuprum-streams" + target_path = _write_target(crate, target) + _set_package_field(crate / "Cargo.toml", f"{switch} = false") + + roots = safe_target_roots(root) + + assert target_path not in roots, f"{switch} = false must disable {target}" + check_safe_policy(root) + + +def test_build_false_disables_the_default_build_script(tmp_path: Path) -> None: + """Cargo's ``build = false`` removes the otherwise automatic build script.""" + root = copy_boundary_repository(tmp_path) / "rust" + crate = root / "cuprum-streams" + build_script = _write_target(crate, "build.rs") + assert build_script in safe_target_roots(root), ( + "the default build script is automatic" + ) + + _set_package_field(crate / "Cargo.toml", "build = false") + + assert build_script not in safe_target_roots(root), ( + "build = false must remove the automatic build-script target" + ) + check_safe_policy(root) + + +def test_explicit_target_without_unsafe_prohibition_is_rejected(tmp_path: Path) -> None: + """An explicit target does not inherit the safe crate's source attribute.""" + root = copy_boundary_repository(tmp_path) / "rust" + crate = root / "cuprum-streams" + manifest = crate / "Cargo.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8") + + '\n[[bin]]\nname = "unsafe-contract-probe"\npath = "tools/probe.rs"\n', + encoding="utf-8", + ) + _write_target(crate, "tools/probe.rs", "//! Missing safety attribute.\n") + + with pytest.raises(ValueError, match="safe target must forbid unsafe code"): + check_safe_policy(root) + + +@pytest.mark.parametrize("case", OVERRIDING_EXPLICIT_TARGETS) +def test_explicit_target_replaces_its_dormant_automatic_root( + case: tuple[str, str | None, str, str], tmp_path: Path +) -> None: + """Cargo metadata and the safe policy must agree on explicit overrides.""" + target_type, name, dormant, custom = case + root = copy_boundary_repository(tmp_path) / "rust" + crate = root / "cuprum-streams" + manifest = crate / "Cargo.toml" + dormant_path = _write_target( + crate, dormant, "//! Dormant automatic root." + chr(10) + ) + custom_path = _write_target(crate, custom) + _append_explicit_target(manifest, target_type, name, custom) + + roots = safe_target_roots(root) + + assert custom_path in roots, "the explicit target path must remain audited" + assert dormant_path not in roots, ( + "the explicit target must replace its dormant conventional root" + ) + assert roots == cargo_target_roots(crate), ( + "the scanner and Cargo metadata must resolve identical target roots" + ) + check_safe_policy(root) + + +@pytest.mark.parametrize(("target_type", "name", "target"), PATHLESS_EXPLICIT_TARGETS) +def test_pathless_explicit_target_is_discovered_and_checked( + target_type: str, name: str | None, target: str, tmp_path: Path +) -> None: + """Cargo-inferred source paths must remain inside the safe-target contract.""" + root = copy_boundary_repository(tmp_path) / "rust" + crate = root / "cuprum-streams" + manifest = crate / "Cargo.toml" + _set_package_field( + manifest, + "\n".join(( + "autolib = false", + "autobins = false", + "autoexamples = false", + "autotests = false", + "autobenches = false", + )), + ) + _append_pathless_target(manifest, target_type, name) + target_path = _write_target(crate, target, "//! Missing safety attribute.\n") + + assert target_path in safe_target_roots(root), ( + "the pathless Cargo target table must discover its inferred source" + ) + with pytest.raises(ValueError, match="safe target must forbid unsafe code"): + check_safe_policy(root) + + +def test_malformed_manifest_fails_closed_with_its_path(tmp_path: Path) -> None: + """Target discovery must report malformed TOML instead of losing coverage.""" + root = copy_boundary_repository(tmp_path) / "rust" + manifest = root / "cuprum-streams/Cargo.toml" + manifest.write_text("[package\n", encoding="utf-8") + + with pytest.raises(ValueError, match=r"cannot parse TOML manifest .*Cargo.toml"): + safe_target_roots(root) + + +def test_non_utf8_manifest_fails_closed_with_its_path(tmp_path: Path) -> None: + """Manifest decoding failure is an explicit unmeasured-contract error.""" + root = copy_boundary_repository(tmp_path) / "rust" + manifest = root / "cuprum-streams/Cargo.toml" + manifest.write_bytes(b"\xff") + + with pytest.raises(ValueError, match=r"cannot read .*Cargo.toml"): + safe_target_roots(root) + + +def test_unreadable_source_fails_closed_with_its_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A source read error is an unmeasured boundary, never an omitted target.""" + root = copy_boundary_repository(tmp_path) / "rust" + target = root / "cuprum-streams/src/lib.rs" + original_read_text = Path.read_text + + def read_text( + path: Path, encoding: str | None = None, errors: str | None = None + ) -> str: + """Refuse only the selected target while retaining real fixture reads.""" + if path == target: + raise PermissionError + return original_read_text(path, encoding=encoding, errors=errors) + + monkeypatch.setattr(Path, "read_text", read_text) + + with pytest.raises(ValueError, match=r"cannot read .*src/lib.rs"): + check_safe_policy(root) + + +def test_unreadable_automatic_target_directory_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Directory access failure must not be treated as an empty target set.""" + root = copy_boundary_repository(tmp_path) / "rust" + directory = root / "cuprum-streams/tests" + original_iterdir = Path.iterdir + + def iterdir(path: Path) -> object: + """Refuse only the directory whose targets the contract must inspect.""" + if path == directory: + raise PermissionError + return original_iterdir(path) + + monkeypatch.setattr(Path, "iterdir", iterdir) + + with pytest.raises( + ValueError, match=r"cannot inspect automatic target directory .*tests" + ): + safe_target_roots(root) + + +def test_probe_rejection_cannot_replace_source_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The orchestrator rejects a missing attribute before it can append a probe.""" + root = copy_boundary_repository(tmp_path) + target = root / "rust/cuprum-streams/src/lib.rs" + target.write_text("//! Missing safety attribute.\n", encoding="utf-8") + compile_calls: list[Path] = [] + + def compile_workspace(workspace: Path) -> tuple[int, str]: + """Record a forbidden probe attempt without invoking Cargo.""" + compile_calls.append(workspace) + return 1, "probe rejected" + + monkeypatch.setattr(contract, "ROOT", root) + monkeypatch.setattr(contract, "_compile", compile_workspace) + + with pytest.raises(ValueError, match="safe target must forbid unsafe code"): + contract.main() + assert not compile_calls, "source policy must fail before the probe seam runs" + + +@settings(max_examples=24, deadline=None) +@given( + st.lists(st.sampled_from(AUTOMATIC_PROPERTY_TARGETS), max_size=6), + st.lists(st.sampled_from(EXPLICIT_PROPERTY_TARGETS), max_size=6), +) +def test_discovery_is_order_independent_and_policy_requires_every_root( + automatic_paths: list[str], explicit_paths: list[str] +) -> None: + """Generated automatic, nested, explicit, and build roots stay deduplicated.""" + crate = Path("/synthetic/cuprum-streams") + automatic = tuple(crate / path for path in automatic_paths) + explicit = tuple(crate / path for path in explicit_paths) + expected = tuple(sorted({*automatic, *explicit})) + + discovered = discover_safe_target_roots(automatic, explicit) + + assert discovered == expected, "the pure discovery oracle must ignore input order" + if not discovered: + with pytest.raises(ValueError, match="no discoverable Cargo target roots"): + evaluate_safe_target_policy({}) + return + safe_sources = dict.fromkeys(discovered, SAFE_SOURCE) + assert evaluate_safe_target_policy(safe_sources) == discovered, ( + "policy evaluation must retain every discovered root" + ) + missing_attribute = dict(safe_sources) + missing_attribute[discovered[0]] = "//! Missing safety attribute.\n" + with pytest.raises(ValueError, match="safe target must forbid unsafe code"): + evaluate_safe_target_policy(missing_attribute) diff --git a/scripts/tests/test_boundary_workspace.py b/scripts/tests/test_boundary_workspace.py new file mode 100644 index 000000000..d8823767b --- /dev/null +++ b/scripts/tests/test_boundary_workspace.py @@ -0,0 +1,232 @@ +"""Exercise isolated boundary workspace copying and probe topology.""" + +from __future__ import annotations + +import stat +import typing as typ + +import pytest + +from scripts import check_boundary_contract as contract +from scripts.boundary_workspace import copy_workspace +from scripts.check_boundary_contract import safe_target_roots + +if typ.TYPE_CHECKING: + import collections.abc as cabc + from pathlib import Path + +from scripts.tests.boundary_harness_support import ( + cargo_target_roots, + copy_boundary_repository, +) + +SAFE_SOURCE = "//! Safe target.\n#![forbid(unsafe_code)]\n" + + +def test_main_keeps_nested_target_sources_and_omits_root_build_output( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A nested target directory is source, while root target is Cargo output.""" + root = copy_boundary_repository(tmp_path) + rust = root / "rust" + nested = rust / "cuprum-streams/src/bin/target/main.rs" + nested.parent.mkdir(parents=True) + nested.write_text(SAFE_SOURCE, encoding="utf-8") + nested.chmod(stat.S_IRUSR) + build_output = rust / "target/ignored.txt" + build_output.parent.mkdir() + build_output.write_text("build output", encoding="utf-8") + source_targets = safe_target_roots(rust) + workspace = root / ".cache/boundary-contract/workspace" + + copy_workspace(rust, workspace, source_targets) + + copied_nested = workspace / nested.relative_to(rust) + assert copied_nested in cargo_target_roots(workspace / "cuprum-streams"), ( + "Cargo metadata must retain the nested source directory named target" + ) + assert copied_nested.is_file(), "the nested source target must be copied" + assert copied_nested.stat().st_mode & stat.S_IWUSR, ( + "the copied target must be writable for temporary probes" + ) + assert not nested.stat().st_mode & stat.S_IWUSR, ( + "the source target mode must remain unchanged" + ) + assert not (workspace / "target").exists(), ( + "only the workspace-root Cargo build output must be omitted" + ) + scheduled: list[Path] = [] + monkeypatch.setattr(contract, "ROOT", root) + monkeypatch.setattr(contract, "_compile", lambda _workspace: (0, "checked")) + monkeypatch.setattr( + contract, + "_check_target", + lambda _workspace, target, _logs: scheduled.append(target), + ) + + contract.main() + + assert copied_nested in scheduled, ( + "the copied nested target must be scheduled for unsafe probes" + ) + + +def _external_source_links( + root: Path, crate: Path, tmp_path: Path +) -> tuple[tuple[Path, Path, str | None], ...]: + """Create relative and absolute external source-file symlinks.""" + links = ( + ( + crate / "tests/relative.rs", + root / "relative-source.rs", + "../../../relative-source.rs", + ), + (crate / "tests/absolute.rs", tmp_path / "absolute-source.rs", None), + ) + for _link, source, _target in links: + source.write_text(SAFE_SOURCE, encoding="utf-8") + try: + for link, source, target in links: + link.symlink_to(source if target is None else target) + except OSError as error: + pytest.skip(f"the platform cannot create the symlink fixture: {error}") + return links + + +def _recording_probe_compiler( + observed_sources: list[str], +) -> cabc.Callable[[Path], tuple[int, str]]: + """Return a compiler seam that rejects probes in the copied source tree.""" + + def compile_workspace(workspace: Path) -> tuple[int, str]: + """Reject only the probes applied to the isolated copied sources.""" + source = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted((workspace / "cuprum-streams").rglob("*.rs")) + ) + observed_sources.append(source) + if any(probe in source for probe in contract.PROBES): + return 1, "error: forbid(unsafe_code)" + return 0, "checked" + + return compile_workspace + + +def _assert_external_links_are_isolated( + crate: Path, + copied_crate: Path, + links: tuple[tuple[Path, Path, str | None], ...], + original_targets: tuple[Path, ...], +) -> None: + """Require metadata parity, materialization, and untouched external bytes.""" + copied_links = tuple( + copied_crate / link.relative_to(crate) for link, _source, _target in links + ) + assert cargo_target_roots(copied_crate) == tuple( + copied_crate / path.relative_to(crate) for path in original_targets + ), "Cargo metadata must retain the copied source-file targets" + assert all(path.is_file() and not path.is_symlink() for path in copied_links), ( + "external source-file links must be materialized inside the copied workspace" + ) + assert all( + source.read_text(encoding="utf-8") == SAFE_SOURCE + for _link, source, _target in links + ), "external source bytes must survive the copied probes" + + +def test_main_materializes_external_source_file_symlinks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """External source links become copied files before the unsafe probes run.""" + root = copy_boundary_repository(tmp_path) + crate = root / "rust/cuprum-streams" + links = _external_source_links(root, crate, tmp_path) + before = cargo_target_roots(crate) + assert links[0][0] in before, "Cargo must recognize the relative source-file link" + assert links[1][0] in before, "Cargo must recognize the absolute source-file link" + observed_sources: list[str] = [] + boundary_compile = contract._compile + monkeypatch.setattr(contract, "ROOT", root) + monkeypatch.setattr( + contract, "_compile", _recording_probe_compiler(observed_sources) + ) + + contract.main() + + copied_crate = root / ".cache/boundary-contract/workspace/cuprum-streams" + code, output = boundary_compile(copied_crate.parent) + assert code == 0, output + _assert_external_links_are_isolated(crate, copied_crate, links, before) + assert all( + any(probe in source for source in observed_sources) for probe in contract.PROBES + ), "each unsafe probe must reach the isolated copied sources" + + +@pytest.mark.parametrize( + ("target_path", "expected_error"), + [ + ("../../outside/target.rs", "escapes the Rust workspace"), + ("/unused", "escapes the Rust workspace"), + ("tools/target.rs", "symlinked parent directory"), + ], +) +def test_main_rejects_explicit_targets_that_escape_the_copy( + target_path: str, + expected_error: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Explicit paths cannot reach original sources through escapes or directories.""" + root = copy_boundary_repository(tmp_path) + crate = root / "rust/cuprum-streams" + external = root / "outside/target.rs" + external.parent.mkdir() + external.write_text(SAFE_SOURCE, encoding="utf-8") + if target_path == "/unused": + target_path = str(external) + if target_path.startswith("tools/"): + (crate / "tools").symlink_to("../../outside", target_is_directory=True) + manifest = crate / "Cargo.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8") + + f'\n[[bin]]\nname = "escaped-target"\npath = "{target_path}"\n', + encoding="utf-8", + ) + monkeypatch.setattr(contract, "ROOT", root) + + with pytest.raises(ValueError, match=expected_error): + contract.main() + + +def test_probe_appended_preserves_crlf_bytes_after_a_write_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Probe restoration preserves raw bytes even when its first write fails.""" + target = tmp_path / "target.rs" + original = b"//! CRLF source.\r\n#![forbid(unsafe_code)]\r\n" + target.write_bytes(original) + probe = "pub fn unsafe_probe() { unsafe {} }" + with contract._probe_appended(target, probe): + assert target.read_bytes() == original + b"\n" + probe.encode() + b"\n", ( + "the probe must preserve the original CRLF bytes" + ) + assert target.read_bytes() == original, "the normal probe path must restore bytes" + original_write = type(target).write_bytes + + def write_bytes(path: Path, data: bytes) -> int: + """Simulate a partial first write while allowing the restoration write.""" + if path == target and data != original: + original_write(path, data[:1]) + msg = "injected probe write failure" + raise OSError(msg) + return original_write(path, data) + + monkeypatch.setattr(type(target), "write_bytes", write_bytes) + with ( + pytest.raises(OSError, match="injected probe write failure"), + contract._probe_appended(target, probe), + ): + pass + assert target.read_bytes() == original, ( + "the failed first probe write must still restore the original raw bytes" + ) diff --git a/scripts/tests/test_rust_lint_baseline_contract.py b/scripts/tests/test_rust_lint_baseline_contract.py new file mode 100644 index 000000000..ece63e37f --- /dev/null +++ b/scripts/tests/test_rust_lint_baseline_contract.py @@ -0,0 +1,219 @@ +"""Keep the committed Rust lint baseline complete and executable.""" + +from __future__ import annotations + +import re +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] - this test expands a fixed local Make recipe. +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CLIPPY_LINTS = { + "allow_attributes", + "allow_attributes_without_reason", + "blanket_clippy_restriction_lints", + "cognitive_complexity", + "disallowed_methods", + "needless_pass_by_value", + "implicit_hasher", + "missing_assert_message", + "dbg_macro", + "print_stdout", + "print_stderr", + "unwrap_used", + "expect_used", + "indexing_slicing", + "string_slice", + "integer_division", + "integer_division_remainder_used", + "panic_in_result_fn", + "unreachable", + "host_endian_bytes", + "little_endian_bytes", + "big_endian_bytes", + "let_underscore_must_use", + "or_fun_call", + "option_if_let_else", + "self_named_module_files", + "shadow_reuse", + "shadow_same", + "shadow_unrelated", + "str_to_string", + "string_lit_as_bytes", + "try_err", + "unneeded_field_pattern", + "use_self", + "float_arithmetic", + "cast_possible_truncation", + "cast_possible_wrap", + "cast_precision_loss", + "lossy_float_literal", + "missing_const_for_fn", + "must_use_candidate", + "unused_async", + "missing_panics_doc", + "error_impl_error", + "result_large_err", +} +RUST_LINTS = {"missing_docs", "renamed_and_removed_lints", "unknown_lints"} +RUSTDOC_LINTS = { + "bare_urls", + "broken_intra_doc_links", + "invalid_codeblock_attributes", + "invalid_html_tags", + "missing_crate_level_docs", + "private_intra_doc_links", + "unescaped_backticks", +} +DISALLOWED_METHODS = { + "std::env::var", + "std::env::var_os", + "std::env::vars", + "std::env::vars_os", + "std::env::set_var", + "std::env::remove_var", + "std::env::set_current_dir", +} +EXPECTED_PEDANTIC = {"level": "warn", "priority": -1} +EXPECTED_UNEXPECTED_CFGS = {"level": "warn", "check-cfg": ["cfg(kani)"]} +EXPECTED_CLIPPY_OPTIONS = { + "cognitive-complexity-threshold": 9, + "too-many-arguments-threshold": 4, + "too-many-lines-threshold": 70, + "excessive-nesting-threshold": 4, + "allow-expect-in-tests": True, +} + + +def _cargo_manifest() -> dict[str, object]: + """Load the workspace manifest that owns the shared lint policy.""" + with (ROOT / "rust/Cargo.toml").open("rb") as manifest: + return tomllib.load(manifest) + + +def _clippy_config() -> dict[str, object]: + """Load the local Clippy configuration beside the workspace manifest.""" + with (ROOT / "rust/clippy.toml").open("rb") as config: + return tomllib.load(config) + + +def _typos_local_config() -> dict[str, object]: + """Load local spelling exemptions that supplement the shared dictionary.""" + with (ROOT / "typos.local.toml").open("rb") as config: + return tomllib.load(config) + + +def _dry_run_test_rust() -> str: + """Expand the Linux doctest recipe without running Cargo or prerequisites.""" + make = shutil.which("make") + assert make is not None, "the Make contract requires GNU Make on PATH" + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [ + make, + "--dry-run", + "test-rust", + "CARGO=probe-cargo", + "DEV_FAST_HOST_IS_LINUX=yes", + "BUILD_JOBS=--jobs 7", + ], + capture_output=True, + check=True, + cwd=ROOT, + text=True, + ) + return completed.stdout + + +def test_workspace_declares_every_required_rust_lint_at_the_expected_level() -> None: + """The baseline must not lose a denied lint during future table edits.""" + workspace = _cargo_manifest()["workspace"] + assert isinstance(workspace, dict), "the workspace table must be a TOML mapping" + lints = workspace["lints"] + assert isinstance(lints, dict), "the workspace must declare a lint table" + clippy = lints["clippy"] + rust = lints["rust"] + rustdoc = lints["rustdoc"] + assert isinstance(clippy, dict), "the workspace must declare Clippy lints" + assert isinstance(rust, dict), "the workspace must declare Rust lints" + assert isinstance(rustdoc, dict), "the workspace must declare Rustdoc lints" + assert clippy["pedantic"] == EXPECTED_PEDANTIC, ( + "pedantic must remain the warn-level base" + ) + assert {name: clippy[name] for name in CLIPPY_LINTS} == dict.fromkeys( + CLIPPY_LINTS, "deny" + ), "every required Clippy lint must stay denied" + assert {name: rust[name] for name in RUST_LINTS} == dict.fromkeys( + RUST_LINTS, "deny" + ), "every required Rust lint must stay denied" + assert {name: rustdoc[name] for name in RUSTDOC_LINTS} == dict.fromkeys( + RUSTDOC_LINTS, "deny" + ), "every required Rustdoc lint must stay denied" + assert "unsafe_code" not in rust, ( + "the audited syscall and FFI boundaries preclude a workspace-wide unsafe ban" + ) + assert rust["unexpected_cfgs"] == EXPECTED_UNEXPECTED_CFGS, ( + "the Kani conditional compilation contract must remain reachable" + ) + + +def test_clippy_configuration_keeps_the_approved_thresholds_and_methods() -> None: + """Thresholds and environment injection must stay auditable local policy.""" + config = _clippy_config() + assert { + name: config[name] + for name in ( + "cognitive-complexity-threshold", + "too-many-arguments-threshold", + "too-many-lines-threshold", + "excessive-nesting-threshold", + "allow-expect-in-tests", + ) + } == EXPECTED_CLIPPY_OPTIONS, "the approved Clippy options must not drift" + methods = config["disallowed-methods"] + assert isinstance(methods, list), "disallowed methods must remain a TOML list" + assert {method["path"] for method in methods if isinstance(method, dict)} == ( + DISALLOWED_METHODS + ), "the seven environment methods must stay disallowed" + assert all( + isinstance(method, dict) + and isinstance(method.get("reason"), str) + and method["reason"] + for method in methods + ), "each disallowed environment method needs a local injection rationale" + + +def test_cargo_flag_exemption_is_limited_to_the_external_flag() -> None: + """The Cargo CLI spelling must not exempt ordinary American-spelled prose.""" + patterns = _typos_local_config()["patterns"] + assert isinstance(patterns, dict), "local spelling patterns must be a mapping" + ignored = patterns["ignore"] + assert isinstance(ignored, list), "local spelling ignore patterns must be a list" + pattern = next( + entry + for entry in ignored + if isinstance(entry, str) and entry.startswith("--col") + ) + external_flag = '"--col' + 'or",' + assert re.search(pattern, external_flag), ( + "the external Cargo flag must match its documented exemption" + ) + ordinary_prose = "".join(("col", "or in ordinary prose")) + assert re.search(pattern, ordinary_prose) is None, ( + "ordinary prose must retain the Oxford-spelling check" + ) + + +def test_doctest_recipe_passes_full_rustdoc_warning_flags_and_jobs() -> None: + """Require the separate gate to use pinned nightly rustdoc warnings.""" + output = _dry_run_test_rust() + doctest = next( + line + for line in output.splitlines() + if "test --workspace --doc --all-features --jobs 7" in line + ) + assert ( + 'RUSTDOCFLAGS="--cfg docsrs -D warnings -Zunstable-options ' + "--display-doctest-warnings --doctest-build-arg=-D " + '--doctest-build-arg=warnings"' + ) in doctest, "the doctest recipe must retain every rustdoc warning flag" diff --git a/tests/test_native_sdist.py b/tests/test_native_sdist.py index db8c6fc13..332331115 100644 --- a/tests/test_native_sdist.py +++ b/tests/test_native_sdist.py @@ -60,7 +60,13 @@ def test_source_distribution_retains_native_workspace( workspace = repo_root() / "rust" expected: set[tuple[str, ...]] = { ("rust", name) - for name in ("Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "dylint.toml") + for name in ( + "Cargo.toml", + "Cargo.lock", + "clippy.toml", + "rust-toolchain.toml", + "dylint.toml", + ) } for crate in ("cuprum-rust", "cuprum-streams", "cuprum-native-io"): expected.add(("rust", crate, "Cargo.toml")) diff --git a/typos.local.toml b/typos.local.toml index 6c5d7a6e5..1866e17de 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -40,6 +40,9 @@ ignore = [ # backticks should still be spell-checked. '\bbuild_native_wheel_artifact\b', '--artifact-server-path(?:[=\s"`]|$)', + # Cargo fixes this command-line flag's US spelling; ordinary prose remains + # checked. + '--color(?:[=,\s"]|$)', '`color`', '\brust-analyzer\b', # `mold` is the fixed external linker name. Typos tokenizes words before diff --git a/typos.toml b/typos.toml index a35018209..d5a6c6421 100644 --- a/typos.toml +++ b/typos.toml @@ -37,6 +37,7 @@ extend-ignore-re = [ "\"mold\\\\t--version\"", "--artifact-name(?:[=\\s\"`]|$)", "--artifact-server-path(?:[=\\s\"`]|$)", + "--color(?:[=,\\s\"]|$)", "-fuse-ld=mold", "/artifacts(?:[/?#\"`]|$)", "Azure\\s+Architecture\\s+Center\\s*\\|\\s*Microsoft\\s+Learn\\b",