diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index f2cddac488..2229332e41 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -1,5 +1,5 @@ { - "image_tag": "sha-473fe44", + "image_tag": "sha-060957e", "configs": { "ubuntu": [ { diff --git a/.github/workflows/build-nix-images.yml b/.github/workflows/build-nix-images.yml index e47a1f93ff..8ecf4912f5 100644 --- a/.github/workflows/build-nix-images.yml +++ b/.github/workflows/build-nix-images.yml @@ -5,15 +5,13 @@ on: branches: - develop paths: - - ".github/workflows/build-nix-images.yml" - "flake.nix" - "flake.lock" - "rust-toolchain.toml" - "nix/**" - "!nix/docker/README.md" - "!nix/devshell.nix" - - "!nix/check-tools/*.txt" - - "bin/check-tools.sh" + - "!nix/check-tools/**" - "bin/default-loader-path.sh" - "bin/install-sanitizer-libs.sh" pull_request: @@ -25,7 +23,7 @@ on: - "nix/**" - "!nix/docker/README.md" - "!nix/devshell.nix" - - "!nix/check-tools/*.txt" + - "!nix/check-tools/**" - "bin/check-tools.sh" - "bin/default-loader-path.sh" - "bin/install-sanitizer-libs.sh" diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index 6ddc6cdac9..6e613b822a 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -34,7 +34,7 @@ permissions: jobs: audit: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e permissions: contents: read # Needed to open an issue on scheduled failures. diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index d4fb6faeca..735ddfc906 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -41,7 +41,7 @@ env: jobs: build: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index 68ab531882..8e3b80e318 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -34,7 +34,7 @@ jobs: needs: [determine-files] if: ${{ needs.determine-files.outputs.cpp_changed_files != '' || needs.determine-files.outputs.need_full_run == 'true' }} runs-on: ["self-hosted", "Linux", "X64", "heavy"] - container: "ghcr.io/xrplf/xrpld/nix-debian:sha-473fe44" + container: "ghcr.io/xrplf/xrpld/nix-debian:sha-060957e" permissions: contents: read issues: write diff --git a/.github/workflows/reusable-rust.yml b/.github/workflows/reusable-rust.yml index a0199f0129..12a31aacbd 100644 --- a/.github/workflows/reusable-rust.yml +++ b/.github/workflows/reusable-rust.yml @@ -1,8 +1,9 @@ # Clippy, coverage and documentation for the Rust crates in crates/. Each runs # as an independent job on a GitHub-hosted runner, but inside the same container # image used to build the crates in the C++/Corrosion path, so the toolchain -# (and therefore the lints, coverage instrumentation and the cargo cache) matches -# what production builds use. +# (and therefore the lints and the cargo cache) matches what production builds +# use. Coverage is the exception: it needs the nightly rustc that honours +# #[coverage(off)], which the image carries alongside the pinned stable. # # Rust unit tests are deliberately NOT run here. They run as part of the C++ # build (reusable-build-test-config.yml), which already compiles the crates on a @@ -27,7 +28,7 @@ permissions: jobs: clippy: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -40,11 +41,14 @@ jobs: coverage: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Use the nightly Rust toolchain + run: rust-nightly path >>"${GITHUB_PATH}" + - name: Use cargo artifacts cache uses: ./.github/actions/cargo-cache @@ -66,7 +70,7 @@ jobs: doc: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index 69de12db0e..0511458e7f 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -40,7 +40,7 @@ defaults: jobs: upload: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e env: REMOTE_NAME: ${{ inputs.remote_name }} CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f223ab1684..8721ad7863 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -70,6 +70,11 @@ repos: language: system types: [rust] pass_filenames: false # rustfmt formats the whole workspace + - id: check-coverage-attrs + name: check Rust coverage attributes + entry: ./bin/pre-commit/check_rust_coverage_attrs.py + language: python + files: ^crates/.*\.rs$ - repo: https://github.com/BlankSpruce/gersemi-pre-commit rev: e98930bdc210d3387007f9252d8c1694ea7e410f # frozen: 0.27.7 diff --git a/bin/check-tools.sh b/bin/check-tools.sh index 8273375428..ed76861aa4 100755 --- a/bin/check-tools.sh +++ b/bin/check-tools.sh @@ -158,6 +158,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then check cargo-nextest cargo nextest --version check clippy-driver check rust-analyzer + check rust-nightly rust-nightly run rustc --version check rustc check rustfmt fi diff --git a/bin/pre-commit/check_rust_coverage_attrs.py b/bin/pre-commit/check_rust_coverage_attrs.py new file mode 100755 index 0000000000..dba56d5756 --- /dev/null +++ b/bin/pre-commit/check_rust_coverage_attrs.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +Check that Rust unit tests stay out of the coverage report. + +cargo-llvm-cov instruments the test code along with everything else, so a test +module that is not excluded counts its own body as covered and inflates the +reported number. Excluding it takes two attributes: + + * every `#[cfg(test)]` module carries + `#[cfg_attr(coverage_nightly, coverage(off))]`; + * every crate root (lib.rs, main.rs) carries + `#![cfg_attr(coverage_nightly, feature(coverage_attribute))]`, which the + attribute above needs in order to compile. + +Both are inert outside the coverage job: cargo-llvm-cov defines +`coverage_nightly` only when it runs on a nightly toolchain. + +The crate-root gate is checked even in a crate that has no tests yet, because +that is what lets the first test module added later carry the attribute without +a build failure. Missing it is a hard error, so it cannot go unnoticed; a +missing `coverage(off)` fails open, which is why this check exists. + +Matching is on exact attribute text, which works because `cargo fmt` runs over +the whole workspace in the hook ahead of this one: rustfmt puts every attribute +on its own line and normalizes what is inside it, turning `#[cfg( test )]` +and `#[cfg(test,)]` alike into `#[cfg(test)]`. So there is nothing here that +parses Rust. The price is that a cfg this file does not spell out literally -- +`all(test, ...)`, `any(test, ...)`, `not(test)` -- is reported rather than +classified, on the grounds that guessing at coverage semantics is how a check +like this ends up quietly wrong. + +Usage: ./bin/pre-commit/check_rust_coverage_attrs.py ... + +Exit status is non-zero if any violation is found. +""" + +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +CRATE_ROOTS = {"lib.rs", "main.rs"} + +FEATURE_ATTR = "#![cfg_attr(coverage_nightly, feature(coverage_attribute))]" +COVERAGE_OFF_ATTR = "#[cfg_attr(coverage_nightly, coverage(off))]" +CFG_TEST_ATTR = "#[cfg(test)]" + +# Any other cfg that mentions `test`. String literals are blanked before this +# runs, so `feature = "test"` does not read as the `test` cfg. +RE_CFG_MENTIONS_TEST = re.compile(r"^#\[cfg\(.*\btest\b.*\)\]$") +RE_STRING = re.compile(r'"(?:[^"\\]|\\.)*"') +RE_MOD = re.compile(r"^(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_]\w*)") + + +@dataclass(frozen=True) +class Finding: + line: int + label: str + message: str + + +def _check_module(attrs: list[str], line: int, name: str) -> list[Finding]: + """Findings for one module, given the attributes attached to it.""" + if COVERAGE_OFF_ATTR in attrs: + return [] # excluded from coverage; which cfg gates it does not matter + if CFG_TEST_ATTR in attrs: + return [ + Finding( + line, + "missing-coverage-off", + f"`mod {name}` is #[cfg(test)] but not excluded from coverage; " + f"add {COVERAGE_OFF_ATTR}", + ) + ] + unclassified = [ + attr for attr in attrs if RE_CFG_MENTIONS_TEST.match(RE_STRING.sub('""', attr)) + ] + if unclassified: + return [ + Finding( + line, + "unclassified-cfg", + f"`mod {name}` is gated on {unclassified[0]}, which this check " + f"cannot tell apart from a module that ships in the library; " + f"add {COVERAGE_OFF_ATTR} if it is test-only, or teach this " + f"check the cfg if it is not", + ) + ] + return [] + + +def _check_test_modules(lines: list[str]) -> list[Finding]: + """Findings for every test module that is not excluded from coverage.""" + findings: list[Finding] = [] + attrs: list[str] = [] + attrs_line = 0 + for number, raw in enumerate(lines, start=1): + stripped = raw.strip() + # Blank lines and comments are allowed between an attribute and its item. + if not stripped or stripped.startswith("//"): + continue + if stripped.startswith("#["): + if not attrs: + attrs_line = number + attrs.append(stripped) + continue + module = RE_MOD.match(stripped) + if module is not None and attrs: + findings += _check_module(attrs, attrs_line, module.group(1)) + attrs = [] + return findings + + +def _check_crate_root(name: str, lines: list[str]) -> list[Finding]: + """A finding if a crate root is missing the coverage_attribute feature gate.""" + if name not in CRATE_ROOTS: + return [] + if any(line.strip() == FEATURE_ATTR for line in lines): + return [] + return [ + Finding( + 1, + "missing-feature-gate", + f"crate root is missing {FEATURE_ATTR}", + ) + ] + + +def check_source(name: str, text: str) -> list[Finding]: + """Findings for one file's contents; `name` is its base name (lib.rs, ...).""" + lines = text.splitlines() + return _check_crate_root(name, lines) + _check_test_modules(lines) + + +def check_file(path: Path) -> list[Finding]: + return check_source(path.name, path.read_text(encoding="utf-8")) + + +def main() -> int: + total = 0 + for path in (Path(name) for name in sys.argv[1:]): + for finding in check_file(path): + total += 1 + print(f"{path}:{finding.line}: {finding.label}: {finding.message}") + return 1 if total else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 0bb0e9c550..36c79f1752 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -8,6 +8,9 @@ cxx = { version = "1.0.198", features = ["c++20"] } [workspace.package] edition = "2024" +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(coverage)', 'cfg(coverage_nightly)' ] } + [profile.release] opt-level = 3 overflow-checks = true diff --git a/crates/hello_world/Cargo.toml b/crates/hello_world/Cargo.toml index 2e5a329c9a..50f02cc95f 100644 --- a/crates/hello_world/Cargo.toml +++ b/crates/hello_world/Cargo.toml @@ -8,3 +8,6 @@ crate-type = ["staticlib"] [dependencies] cxx.workspace = true + +[lints] +workspace = true diff --git a/crates/hello_world/src/lib.rs b/crates/hello_world/src/lib.rs index b1cb121fa0..6f69801a1d 100644 --- a/crates/hello_world/src/lib.rs +++ b/crates/hello_world/src/lib.rs @@ -1,3 +1,5 @@ +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + #[cxx::bridge(namespace = "rs::hello_world")] mod ffi { extern "Rust" { @@ -8,3 +10,14 @@ mod ffi { pub fn hello_world() -> String { "hello_world".to_string() } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn hello_world_returns_hello_world() { + assert_eq!(hello_world(), "hello_world") + } +} diff --git a/nix/check-tools/macos.txt b/nix/check-tools/macos.txt index 8edfeef311..d2dee651f1 100644 --- a/nix/check-tools/macos.txt +++ b/nix/check-tools/macos.txt @@ -131,6 +131,9 @@ Rust toolchain: ✅ rust-analyzer rust-analyzer 1.97.1 (8bab26f4 2026-07-14) /nix/store/j6apc5pmd0giy15da9p650r8zklslmvi-rust-analyzer-preview-1.97.1-aarch64-apple-darwin/bin/rust-analyzer + ✅ rust-nightly + rustc 1.99.0-nightly (87e5904f5 2026-07-20) + /nix/store/fqpjz4l0nsnji8b2pz57mnj0akbp6hcl-rust-nightly/bin/rust-nightly ✅ rustc rustc 1.97.1 (8bab26f4f 2026-07-14) /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/rustc @@ -140,4 +143,4 @@ Rust toolchain: Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -✅ All 44 checked tools are present and runnable. +✅ All 45 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-amd64.txt b/nix/check-tools/nix-ubuntu-amd64.txt index 28b6c38014..ba5d5e65b1 100644 --- a/nix/check-tools/nix-ubuntu-amd64.txt +++ b/nix/check-tools/nix-ubuntu-amd64.txt @@ -131,6 +131,9 @@ Rust toolchain: ✅ rust-analyzer rust-analyzer 1.97.1 (8bab26f 2026-07-14) /nix/store/lr3m97p3hx1k22a7c44pb0wa7rbayhfi-rust-analyzer-preview-1.97.1-x86_64-unknown-linux-gnu/bin/rust-analyzer + ✅ rust-nightly + rustc 1.99.0-nightly (87e5904f5 2026-07-20) + /nix/store/j7kf7a5h4xypzp6x1skg4dsdx2k4fwb3-rust-nightly/bin/rust-nightly ✅ rustc rustc 1.97.1 (8bab26f4f 2026-07-14) /nix/store/40d3mzka7r1ps71l0yv2fs6616nbw85m-rust-minimal-1.97.1/bin/rustc @@ -168,4 +171,4 @@ Mold: Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -✅ All 52 checked tools are present and runnable. +✅ All 53 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-arm64.txt b/nix/check-tools/nix-ubuntu-arm64.txt index b3b5885a7f..2b45230327 100644 --- a/nix/check-tools/nix-ubuntu-arm64.txt +++ b/nix/check-tools/nix-ubuntu-arm64.txt @@ -131,6 +131,9 @@ Rust toolchain: ✅ rust-analyzer rust-analyzer 1.97.1 (8bab26f 2026-07-14) /nix/store/262830dlw2517lnagfx7i7agqgl4fmsd-rust-analyzer-preview-1.97.1-aarch64-unknown-linux-gnu/bin/rust-analyzer + ✅ rust-nightly + rustc 1.99.0-nightly (87e5904f5 2026-07-20) + /nix/store/c59pxk1yikdlf129qwyg4fplmxcrha0k-rust-nightly/bin/rust-nightly ✅ rustc rustc 1.97.1 (8bab26f4f 2026-07-14) /nix/store/a6p27cg6b8szfixfyvkssx6l0c345zw8-rust-minimal-1.97.1/bin/rustc @@ -168,4 +171,4 @@ Mold: Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -✅ All 52 checked tools are present and runnable. +✅ All 53 checked tools are present and runnable.