diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2df162d89..c20f024e0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,15 @@ version: 2 updates: + - package-ecosystem: "rust-toolchain" + directory: "/" + target-branch: "develop" + schedule: + interval: "weekly" + open-pull-requests-limit: 1 + labels: + - "dependencies" + - "rust" + - package-ecosystem: "npm" directory: "/" target-branch: "develop" diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 13de8e648..87f0f2da2 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -53,10 +53,10 @@ jobs: with: version: "0.8.6" enable-cache: false - - name: Install Rust stable - run: rustup toolchain install stable --profile minimal + - name: Install Rust 1.97.1 toolchain + run: rustup toolchain install 1.97.1 --profile minimal - name: Add Windows target - run: rustup target add $env:BANDSCOPE_TARGET_TRIPLE --toolchain stable + run: rustup target add $env:BANDSCOPE_TARGET_TRIPLE --toolchain 1.97.1 - name: Verify Windows antivirus baseline shell: pwsh run: | @@ -152,10 +152,10 @@ jobs: with: version: "0.8.6" enable-cache: false - - name: Install Rust stable - run: rustup toolchain install stable --profile minimal + - name: Install Rust 1.97.1 toolchain + run: rustup toolchain install 1.97.1 --profile minimal - name: Add Windows arm target - run: rustup target add $env:BANDSCOPE_TARGET_TRIPLE --toolchain stable + run: rustup target add $env:BANDSCOPE_TARGET_TRIPLE --toolchain 1.97.1 - name: Verify Windows antivirus baseline shell: pwsh run: | @@ -262,10 +262,10 @@ jobs: with: version: "0.8.6" enable-cache: false - - name: Install Rust stable - run: rustup toolchain install stable --profile minimal + - name: Install Rust 1.97.1 toolchain + run: rustup toolchain install 1.97.1 --profile minimal - name: Add macOS Intel target - run: rustup target add "$BANDSCOPE_TARGET_TRIPLE" --toolchain stable + run: rustup target add "$BANDSCOPE_TARGET_TRIPLE" --toolchain 1.97.1 - name: Install create-dmg run: brew install create-dmg - name: Activate and verify pinned npm runtime @@ -326,10 +326,10 @@ jobs: with: version: "0.8.6" enable-cache: false - - name: Install Rust stable - run: rustup toolchain install stable --profile minimal + - name: Install Rust 1.97.1 toolchain + run: rustup toolchain install 1.97.1 --profile minimal - name: Add macOS arm target - run: rustup target add "$BANDSCOPE_TARGET_TRIPLE" --toolchain stable + run: rustup target add "$BANDSCOPE_TARGET_TRIPLE" --toolchain 1.97.1 - name: Install create-dmg run: brew install create-dmg - name: Activate and verify pinned npm runtime diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e743c2ff..eb6ff4d39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "22.22.3" @@ -56,6 +57,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "22.22.3" @@ -74,8 +76,8 @@ jobs: run: npm ci - name: Sync Python dependencies run: uv sync --project services/analysis-engine --group dev --frozen - - name: Install stable Rust toolchain - run: rustup toolchain install stable --profile minimal + - name: Install Rust 1.97.1 toolchain + run: rustup toolchain install 1.97.1 --profile minimal - name: Build and install Rust numeric extension run: | VENV_PY="$PWD/services/analysis-engine/.venv/bin/python" @@ -95,6 +97,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "22.22.3" @@ -105,13 +108,15 @@ jobs: run: | test "$(npm --version)" = "$EXPECTED_NPM_VERSION" npm run check:npm-runtime - - name: Install stable Rust toolchain - run: rustup toolchain install stable --profile minimal + - name: Install Rust 1.97.1 toolchain + run: rustup toolchain install 1.97.1 --profile minimal - name: Install node dependencies run: npm ci - name: Build frontend run: npm run build --workspace @bandscope/desktop - name: Check Tauri shell - run: cargo +stable check --manifest-path apps/desktop/src-tauri/Cargo.toml --locked + run: cargo +1.97.1 check --manifest-path apps/desktop/src-tauri/Cargo.toml --locked - name: Test Tauri shell - run: cargo +stable test --manifest-path apps/desktop/src-tauri/Cargo.toml --locked + run: cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml --locked + - name: Test desktop core integration contracts + run: cargo +1.97.1 test --manifest-path apps/desktop/core/Cargo.toml --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa69a973c..fcca4d640 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,8 +42,8 @@ jobs: with: version: "0.8.6" enable-cache: false - - name: Install Rust stable - run: rustup toolchain install stable --profile minimal + - name: Install Rust 1.97.1 toolchain + run: rustup toolchain install 1.97.1 --profile minimal - name: Activate and verify pinned npm runtime run: | corepack enable npm diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 07754a782..48c6c578e 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -56,13 +56,13 @@ jobs: - name: Run Bandit working-directory: services/analysis-engine run: uv run bandit -c pyproject.toml -r src - - name: Install stable Rust toolchain - run: rustup toolchain install stable --profile minimal + - name: Install Rust 1.97.1 toolchain + run: rustup toolchain install 1.97.1 --profile minimal - name: Install cargo-audit - run: cargo +stable install cargo-audit --locked + run: cargo +1.97.1 install cargo-audit --locked - name: Audit Rust dependencies working-directory: apps/desktop/src-tauri - run: cargo +stable audit + run: cargo +1.97.1 audit - name: Scan for common hardcoded secrets run: | ! git grep -nE '(g[h]p_|g[h]o_|A[K]IA[0-9A-Z]{16}|A[I]za[0-9A-Za-z\-_]{35}|BEGIN (R[S]A|E[C]|OPENS[S]H|P[G]P) PRIVATE KEY)' -- . ':(exclude)package-lock.json' ':(exclude)node_modules/**' diff --git a/docs/doctoring/rust-toolchain-freshness.md b/docs/doctoring/rust-toolchain-freshness.md new file mode 100644 index 000000000..d214a4c3f --- /dev/null +++ b/docs/doctoring/rust-toolchain-freshness.md @@ -0,0 +1,51 @@ +# Rust toolchain freshness and reproducibility + +## Decision + +BandScope pins Rust `1.97.1` as the repository build compiler. The root +`rust-toolchain.toml`, product CI, release preflight, dependency audit, and +Windows/macOS amd64/arm64 packaging workflows all use that exact point release. +A floating `stable` selector is not accepted because it can change scientific, +security, and release evidence without a repository diff. + +The compiler pin does not add or raise `package.rust-version` in the workspace. +This change governs BandScope's reviewed build environment; it does not create a +new downstream minimum-supported-Rust promise for reusable crates. + +GitHub Dependabot monitors the root manifest through the `rust-toolchain` +ecosystem against the protected `develop` branch. GitHub documents this +integration as a Dependabot version-update capability rather than a Rust +security-update feed. A future compiler update must therefore arrive as a +reviewable pull request and pass the unchanged-head Windows, macOS, +analysis-engine, Tauri, release-preflight, audit, coverage, and supply-chain +gates. GitHub's platform-level default cooldown for Dependabot version updates +is additional noise control; it is not treated as a repository security or +freshness guarantee. + +`scripts/checks/verify_rust_toolchain.py` runs in the canonical quickcheck and +fails when any executable workflow reintroduces `rustup toolchain install +stable`, `cargo +stable`, or `--toolchain stable`. The same guard binds +`directory`, target branch, and schedule evidence to the actual +`rust-toolchain` Dependabot lane so an unrelated ecosystem entry cannot satisfy +the compiler-update policy. + +Required Rust command evidence is also bound to one inline executable `run:` +step whose exit status cannot be replaced by shell chaining, pipelines, or +background control operators. Arguments such as `--manifest-path`, `--locked`, +and an explicit target triple remain valid, but forms such as `|| true`, +`| cat`, or `; true` fail closed. This prevents an unsuccessfully installed, +checked, tested, or audited Rust toolchain from becoming success-shaped policy +evidence merely because a later shell command returns zero. + +## References + +GitHub. (2025, August 19). *Dependabot now supports Rust toolchain updates*. +GitHub Changelog. +https://github.blog/changelog/2025-08-19-dependabot-now-supports-rust-toolchain-updates/ + +GitHub. (2026, July 14). *Dependabot version updates introduce default package +cooldown*. GitHub Changelog. +https://github.blog/changelog/2026-07-14-dependabot-version-updates-introduce-default-package-cooldown/ + +The Rust Release Team. (2026, July 16). *Announcing Rust 1.97.1*. Rust Blog. +https://blog.rust-lang.org/2026/07/16/Rust-1.97.1/ diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..725551148 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.97.1" +profile = "minimal" diff --git a/scripts/checks/verify_rust_toolchain.py b/scripts/checks/verify_rust_toolchain.py new file mode 100644 index 000000000..49722187e --- /dev/null +++ b/scripts/checks/verify_rust_toolchain.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Fail closed when BandScope Rust workflows drift from the reviewed compiler.""" + +from __future__ import annotations + +import shlex +import sys +import tomllib +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +RUST_TOOLCHAIN = REPOSITORY_ROOT / "rust-toolchain.toml" +DEPENDABOT = REPOSITORY_ROOT / ".github" / "dependabot.yml" +WORKFLOWS = REPOSITORY_ROOT / ".github" / "workflows" +EXPECTED_TOOLCHAIN = "1.97.1" +FLOATING_PATTERNS = ( + "rustup toolchain install stable", + "cargo +stable", + "--toolchain stable", +) +DEPENDABOT_LANE_MARKER = ' - package-ecosystem: "rust-toolchain"' +DEPENDABOT_UPDATE_MARKER = " - package-ecosystem:" +SHELL_CONTROL_CHARACTERS = frozenset("|&;") + + +def _error(message: str) -> None: + """Write one policy violation to stderr.""" + print(f"rust-toolchain-contract: {message}", file=sys.stderr) + + +def _rust_toolchain_dependabot_lane(content: str) -> str | None: + """Return the single Rust toolchain update lane without borrowing sibling fields.""" + lines = content.splitlines() + starts = [index for index, line in enumerate(lines) if line == DEPENDABOT_LANE_MARKER] + if len(starts) != 1: + return None + + start = starts[0] + end = len(lines) + for index in range(start + 1, len(lines)): + if lines[index].startswith(DEPENDABOT_UPDATE_MARKER): + end = index + break + return "\n".join(lines[start:end]) + + +def _workflow_job(content: str, job_name: str) -> str | None: + """Return one exact top-level workflow job so sibling jobs cannot lend evidence.""" + lines = content.splitlines() + marker = f" {job_name}:" + starts = [index for index, line in enumerate(lines) if line == marker] + if len(starts) != 1: + return None + + start = starts[0] + end = len(lines) + for index in range(start + 1, len(lines)): + line = lines[index] + if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): + end = index + break + return "\n".join(lines[start:end]) + + +def _inline_run_commands(job: str) -> tuple[str, ...]: + """Return executable inline ``run:`` payloads from one workflow job. + + Required compiler evidence deliberately stays on one-line ``run:`` steps. A + comment, step name, environment value, nested ``with`` value, or multiline + scalar cannot satisfy the contract accidentally; changing that representation + requires an explicit verifier update and regression rather than silently + broadening the evidence boundary. + """ + lines = job.splitlines() + step_markers = [index for index, line in enumerate(lines) if line == " steps:"] + if len(step_markers) != 1: + return () + + start = step_markers[0] + 1 + end = len(lines) + for index in range(start, len(lines)): + line = lines[index] + if not line.strip() or line.lstrip().startswith("#"): + continue + if line.startswith(" ") and not line.startswith(" "): + end = index + break + + commands: list[str] = [] + for line in lines[start:end]: + if line.startswith(" - run:"): + command = line.removeprefix(" - run:").strip() + elif line.startswith(" run:"): + command = line.removeprefix(" run:").strip() + else: + continue + if command and command not in {"|", ">", "|-", ">-"}: + commands.append(command) + return tuple(commands) + + +def _is_single_shell_command(command: str) -> bool: + """Return whether a run payload has no shell control operator. + + A required Rust command may carry ordinary arguments such as + ``--manifest-path`` or ``--locked``. It may not be chained, piped, or + backgrounded, because a later command could replace the required command's + exit status and manufacture passing policy evidence. + """ + try: + lexer = shlex.shlex(command, posix=True, punctuation_chars="|&;") + lexer.whitespace_split = True + lexer.commenters = "" + tokens = tuple(lexer) + except ValueError: + return False + return bool(tokens) and not any( + token and all(character in SHELL_CONTROL_CHARACTERS for character in token) + for token in tokens + ) + + +def _job_runs_required_command(job: str, required: str) -> bool: + """Return whether one unmasked executable run step owns the Rust evidence.""" + commands = tuple( + command for command in _inline_run_commands(job) if _is_single_shell_command(command) + ) + if required.startswith("--toolchain "): + return any( + command.startswith("rustup target add ") and required in command + for command in commands + ) + return any( + command == required or command.startswith(f"{required} ") + for command in commands + ) + + +def _required_workflow_jobs() -> dict[str, dict[str, tuple[str, ...]]]: + """Return compiler evidence required from each job that owns Rust execution.""" + install = f"rustup toolchain install {EXPECTED_TOOLCHAIN} --profile minimal" + target = f"--toolchain {EXPECTED_TOOLCHAIN}" + return { + "ci.yml": { + "verify": (install,), + "rust-check": ( + install, + f"cargo +{EXPECTED_TOOLCHAIN} check", + f"cargo +{EXPECTED_TOOLCHAIN} test", + f"cargo +{EXPECTED_TOOLCHAIN} test --manifest-path apps/desktop/core/Cargo.toml --locked", + ), + }, + "release.yml": {"release-preflight": (install,)}, + "security-audit.yml": { + "security-backstop": ( + install, + f"cargo +{EXPECTED_TOOLCHAIN} install cargo-audit --locked", + f"cargo +{EXPECTED_TOOLCHAIN} audit", + ) + }, + "build-baseline.yml": { + "build-windows-native": (install, target), + "build-windows-arm64": (install, target), + "build-macos-native": (install, target), + "build-macos-arm64": (install, target), + }, + } + + +def main() -> int: + """Validate the root manifest, update lane, and every Rust-owning workflow job.""" + + failures = 0 + manifest = tomllib.loads(RUST_TOOLCHAIN.read_text(encoding="utf-8")) + toolchain = manifest.get("toolchain", {}) + if toolchain.get("channel") != EXPECTED_TOOLCHAIN: + _error( + "rust-toolchain.toml must pin channel " + f"{EXPECTED_TOOLCHAIN}, found {toolchain.get('channel')!r}" + ) + failures += 1 + if toolchain.get("profile") != "minimal": + _error("rust-toolchain.toml must retain profile = 'minimal'") + failures += 1 + + dependabot = DEPENDABOT.read_text(encoding="utf-8") + dependabot_lane = _rust_toolchain_dependabot_lane(dependabot) + if dependabot_lane is None: + _error("Dependabot Rust toolchain lane is missing or duplicated") + failures += 1 + else: + lane_lines = set(dependabot_lane.splitlines()) + for label, required_line in ( + ('directory: "/"', ' directory: "/"'), + ('target-branch: "develop"', ' target-branch: "develop"'), + ('interval: "weekly"', ' interval: "weekly"'), + ): + if required_line not in lane_lines: + _error(f"Dependabot Rust toolchain lane is missing {label!r}") + failures += 1 + + workflow_paths = sorted((*WORKFLOWS.glob("*.yml"), *WORKFLOWS.glob("*.yaml"))) + workflow_text = "\n".join(path.read_text(encoding="utf-8") for path in workflow_paths) + for pattern in FLOATING_PATTERNS: + if pattern in workflow_text: + _error(f"workflow still contains floating Rust selector {pattern!r}") + failures += 1 + + for filename, job_requirements in _required_workflow_jobs().items(): + path = WORKFLOWS / filename + if not path.is_file(): + _error(f"required Rust workflow {filename!r} is missing") + failures += 1 + continue + content = path.read_text(encoding="utf-8") + for job_name, requirements in job_requirements.items(): + job = _workflow_job(content, job_name) + if job is None: + _error(f"{filename} is missing unique Rust-owning job {job_name!r}") + failures += 1 + continue + for command in requirements: + if not _job_runs_required_command(job, command): + _error(f"{filename} job {job_name!r} is missing {command!r}") + failures += 1 + + if failures: + return 1 + print(f"Rust compiler contract is pinned to {EXPECTED_TOOLCHAIN}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checks/verify_supply_chain.py b/scripts/checks/verify_supply_chain.py index 5b87b8bff..40e588d98 100644 --- a/scripts/checks/verify_supply_chain.py +++ b/scripts/checks/verify_supply_chain.py @@ -1230,7 +1230,7 @@ def _verify_security_audit_coverage(missing: list[str]) -> None: for token in [ "npm audit --workspaces --audit-level=high", "pip-audit --local --strict", - "cargo +stable audit", + "cargo +1.97.1 audit", ]: if audit and not any( command_contains_token_sequence(command, token) for command in audit_run_commands diff --git a/scripts/harness/quickcheck.sh b/scripts/harness/quickcheck.sh index f2b87e4e8..993d3c536 100755 --- a/scripts/harness/quickcheck.sh +++ b/scripts/harness/quickcheck.sh @@ -9,6 +9,7 @@ python3 scripts/checks/verify_security_notes.py python3 scripts/checks/security_gates.py python3 scripts/checks/verify_supply_chain.py python3 scripts/checks/verify_github_bootstrap_policy.py +python3 scripts/checks/verify_rust_toolchain.py npm run lint npm run typecheck npm run test diff --git a/services/analysis-engine/tests/test_ci_source_checkout_contract.py b/services/analysis-engine/tests/test_ci_source_checkout_contract.py new file mode 100644 index 000000000..2e5ff26ff --- /dev/null +++ b/services/analysis-engine/tests/test_ci_source_checkout_contract.py @@ -0,0 +1,40 @@ +"""Contracts for exact source identity in the generic pull-request CI workflow.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_EXACT_SOURCE_REF = "${{ github.event.pull_request.head.sha || github.sha }}" + + +def test_generic_ci_checkouts_bind_the_exact_pull_request_source_head() -> None: + """Reject synthetic pull-request merge refs as generic CI source evidence.""" + workflow_path = _REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + assert isinstance(workflow, dict) + jobs = workflow.get("jobs") + assert isinstance(jobs, dict) + + expected_jobs = {"lock-validation", "verify", "rust-check"} + assert expected_jobs <= jobs.keys() + + for job_name in sorted(expected_jobs): + job = jobs[job_name] + assert isinstance(job, dict) + steps = job.get("steps") + assert isinstance(steps, list) + checkout_steps = [ + step + for step in steps + if isinstance(step, dict) + and isinstance(step.get("uses"), str) + and str(step["uses"]).startswith("actions/checkout@") + ] + assert len(checkout_steps) == 1, f"{job_name} must own one checkout step" + checkout_options = checkout_steps[0].get("with") + assert isinstance(checkout_options, dict) + assert checkout_options.get("persist-credentials") is False + assert checkout_options.get("ref") == _EXACT_SOURCE_REF diff --git a/services/analysis-engine/tests/test_rust_toolchain_policy.py b/services/analysis-engine/tests/test_rust_toolchain_policy.py new file mode 100644 index 000000000..169e790be --- /dev/null +++ b/services/analysis-engine/tests/test_rust_toolchain_policy.py @@ -0,0 +1,288 @@ +"""Regression tests for the repository Rust toolchain policy guard.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import load_module + + +def _job(name: str, *commands: str) -> str: + """Return one minimal workflow job containing the supplied shell commands.""" + lines = [f" {name}:", " steps:"] + lines.extend(f" - run: {command}" for command in commands) + return "\n".join(lines) + + +def _supporting_workflow_contracts(version: str) -> dict[str, str]: + """Return minimal valid fixtures for Rust-owning workflows outside ordinary CI.""" + install = f"rustup toolchain install {version} --profile minimal" + target = f"rustup target add target --toolchain {version}" + return { + "release.yml": "jobs:\n" + _job("release-preflight", install) + "\n", + "security-audit.yml": ( + "jobs:\n" + + _job( + "security-backstop", + install, + f"cargo +{version} install cargo-audit --locked", + f"cargo +{version} audit", + ) + + "\n" + ), + "build-baseline.yml": "jobs:\n" + + "\n".join( + _job(job_name, install, target) + for job_name in ( + "build-windows-native", + "build-windows-arm64", + "build-macos-native", + "build-macos-arm64", + ) + ) + + "\n", + } + + +def _configure_policy_fixture( + module: object, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + manifest: str, + dependabot: str, + workflow: str, +) -> None: + """Point one loaded verifier module at an isolated repository fixture.""" + toolchain_path = tmp_path / "rust-toolchain.toml" + dependabot_path = tmp_path / ".github" / "dependabot.yml" + workflows_path = tmp_path / ".github" / "workflows" + dependabot_path.parent.mkdir(parents=True, exist_ok=True) + workflows_path.mkdir(parents=True, exist_ok=True) + toolchain_path.write_text(manifest, encoding="utf-8") + dependabot_path.write_text(dependabot, encoding="utf-8") + (workflows_path / "ci.yml").write_text(workflow, encoding="utf-8") + version = module.EXPECTED_TOOLCHAIN + for filename, content in _supporting_workflow_contracts(version).items(): + (workflows_path / filename).write_text(content, encoding="utf-8") + + monkeypatch.setattr(module, "RUST_TOOLCHAIN", toolchain_path) + monkeypatch.setattr(module, "DEPENDABOT", dependabot_path) + monkeypatch.setattr(module, "WORKFLOWS", workflows_path) + + +def _complete_workflow_contract(version: str) -> str: + """Return a minimal valid CI fixture with both Rust-owning jobs.""" + install = f"rustup toolchain install {version} --profile minimal" + return ( + "jobs:\n" + + _job("verify", install) + + "\n" + + _job( + "rust-check", + install, + f"cargo +{version} check", + f"cargo +{version} test", + f"cargo +{version} test --manifest-path apps/desktop/core/Cargo.toml --locked", + ) + + "\n" + ) + + +def _complete_dependabot_contract() -> str: + """Return one minimal, complete Rust toolchain Dependabot update lane.""" + return ( + "version: 2\n" + "updates:\n" + ' - package-ecosystem: "rust-toolchain"\n' + ' directory: "/"\n' + ' target-branch: "develop"\n' + " schedule:\n" + ' interval: "weekly"\n' + ) + + +def test_rust_toolchain_policy_accepts_exact_reviewed_contract( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """The verifier accepts one exact compiler pin and monitored update lane.""" + verifier = load_module( + "scripts/checks/verify_rust_toolchain.py", "verify_rust_toolchain_success" + ) + version = verifier.EXPECTED_TOOLCHAIN + _configure_policy_fixture( + verifier, + monkeypatch, + tmp_path, + manifest=( + "[toolchain]\n" + f'channel = "{version}"\n' + 'profile = "minimal"\n' + 'components = ["rustfmt", "clippy"]\n' + ), + dependabot=_complete_dependabot_contract(), + workflow=_complete_workflow_contract(version), + ) + + assert verifier.main() == 0 + captured = capsys.readouterr() + assert captured.err == "" + assert captured.out == f"Rust compiler contract is pinned to {version}.\n" + + +def test_rust_toolchain_policy_rejects_cross_lane_dependabot_evidence( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Unrelated update lanes cannot satisfy the Rust toolchain lane contract.""" + verifier = load_module( + "scripts/checks/verify_rust_toolchain.py", "verify_rust_toolchain_cross_lane" + ) + version = verifier.EXPECTED_TOOLCHAIN + _configure_policy_fixture( + verifier, + monkeypatch, + tmp_path, + manifest=f'[toolchain]\nchannel = "{version}"\nprofile = "minimal"\n', + dependabot=( + "version: 2\n" + "updates:\n" + ' - package-ecosystem: "rust-toolchain"\n' + ' directory: "/wrong"\n' + ' - package-ecosystem: "npm"\n' + ' directory: "/"\n' + ' target-branch: "develop"\n' + " schedule:\n" + ' interval: "weekly"\n' + ), + workflow=_complete_workflow_contract(version), + ) + + assert verifier.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "Rust toolchain lane is missing" in captured.err + assert "directory" in captured.err + assert "target-branch" in captured.err + assert "interval" in captured.err + + +def test_rust_toolchain_policy_fails_closed_on_every_contract_drift( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Manifest, Dependabot, floating selectors, and command drift all fail closed.""" + verifier = load_module( + "scripts/checks/verify_rust_toolchain.py", "verify_rust_toolchain_failure" + ) + _configure_policy_fixture( + verifier, + monkeypatch, + tmp_path, + manifest='[toolchain]\nchannel = "stable"\nprofile = "default"\n', + dependabot='package-ecosystem: "cargo"\n', + workflow=( + "rustup toolchain install stable\n" + "cargo +stable check\n" + "rustup target add x86_64-unknown-linux-gnu --toolchain stable\n" + ), + ) + + assert verifier.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + for expected in ( + "must pin channel", + "must retain profile = 'minimal'", + "Dependabot Rust toolchain lane is missing", + "workflow still contains floating Rust selector", + "ci.yml is missing unique Rust-owning job", + ): + assert expected in captured.err + + +def test_rust_toolchain_policy_rejects_compiler_evidence_borrowed_from_sibling_workflow( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A release lane cannot borrow its compiler pin from another workflow file.""" + verifier = load_module( + "scripts/checks/verify_rust_toolchain.py", "verify_rust_toolchain_cross_workflow" + ) + version = verifier.EXPECTED_TOOLCHAIN + _configure_policy_fixture( + verifier, + monkeypatch, + tmp_path, + manifest=f'[toolchain]\nchannel = "{version}"\nprofile = "minimal"\n', + dependabot=_complete_dependabot_contract(), + workflow=_complete_workflow_contract(version), + ) + (tmp_path / ".github" / "workflows" / "release.yml").write_text( + "jobs:\n" + _job("release-preflight", "echo no-rust-pin") + "\n", + encoding="utf-8", + ) + + assert verifier.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "release.yml" in captured.err + assert "release-preflight" in captured.err + assert f"rustup toolchain install {version} --profile minimal" in captured.err + + +def test_rust_toolchain_policy_rejects_compiler_evidence_borrowed_between_native_jobs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """One native packaging job cannot supply compiler evidence for sibling architectures.""" + verifier = load_module( + "scripts/checks/verify_rust_toolchain.py", "verify_rust_toolchain_cross_job" + ) + version = verifier.EXPECTED_TOOLCHAIN + _configure_policy_fixture( + verifier, + monkeypatch, + tmp_path, + manifest=f'[toolchain]\nchannel = "{version}"\nprofile = "minimal"\n', + dependabot=_complete_dependabot_contract(), + workflow=_complete_workflow_contract(version), + ) + install = f"rustup toolchain install {version} --profile minimal" + target = f"rustup target add target --toolchain {version}" + (tmp_path / ".github" / "workflows" / "build-baseline.yml").write_text( + "jobs:\n" + + _job( + "build-windows-native", + install, + install, + install, + install, + target, + target, + target, + target, + ) + + "\n" + + _job("build-windows-arm64", "echo no-rust-pin") + + "\n" + + _job("build-macos-native", "echo no-rust-pin") + + "\n" + + _job("build-macos-arm64", "echo no-rust-pin") + + "\n", + encoding="utf-8", + ) + + assert verifier.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "build-windows-arm64" in captured.err + assert "build-macos-native" in captured.err + assert "build-macos-arm64" in captured.err diff --git a/services/analysis-engine/tests/test_rust_toolchain_policy_dependabot_effective.py b/services/analysis-engine/tests/test_rust_toolchain_policy_dependabot_effective.py new file mode 100644 index 000000000..e286a4cc0 --- /dev/null +++ b/services/analysis-engine/tests/test_rust_toolchain_policy_dependabot_effective.py @@ -0,0 +1,108 @@ +"""Regression tests for effective Rust-toolchain Dependabot policy fields.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import load_module + + +def _job(name: str, *commands: str) -> str: + """Return one minimal workflow job with executable run steps.""" + lines = [f" {name}:", " steps:"] + lines.extend(f" - run: {command}" for command in commands) + return "\n".join(lines) + + +def test_rust_toolchain_policy_rejects_dependabot_fields_present_only_in_comments( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Comments inside the Rust lane cannot satisfy effective update policy.""" + verifier = load_module( + "scripts/checks/verify_rust_toolchain.py", + "verify_rust_toolchain_commented_dependabot_evidence", + ) + version = verifier.EXPECTED_TOOLCHAIN + toolchain_path = tmp_path / "rust-toolchain.toml" + dependabot_path = tmp_path / ".github" / "dependabot.yml" + workflows_path = tmp_path / ".github" / "workflows" + dependabot_path.parent.mkdir(parents=True, exist_ok=True) + workflows_path.mkdir(parents=True, exist_ok=True) + + toolchain_path.write_text( + f'[toolchain]\nchannel = "{version}"\nprofile = "minimal"\n', + encoding="utf-8", + ) + dependabot_path.write_text( + "version: 2\n" + "updates:\n" + ' - package-ecosystem: "rust-toolchain"\n' + ' # directory: "/"\n' + ' directory: "/wrong"\n' + ' # target-branch: "develop"\n' + ' target-branch: "main"\n' + " schedule:\n" + ' # interval: "weekly"\n' + ' interval: "monthly"\n', + encoding="utf-8", + ) + + install = f"rustup toolchain install {version} --profile minimal" + (workflows_path / "ci.yml").write_text( + "jobs:\n" + + _job("verify", install) + + "\n" + + _job( + "rust-check", + install, + f"cargo +{version} check", + f"cargo +{version} test", + ) + + "\n", + encoding="utf-8", + ) + (workflows_path / "release.yml").write_text( + "jobs:\n" + _job("release-preflight", install) + "\n", + encoding="utf-8", + ) + (workflows_path / "security-audit.yml").write_text( + "jobs:\n" + + _job( + "audit", + install, + f"cargo +{version} install cargo-audit --locked", + f"cargo +{version} audit", + ) + + "\n", + encoding="utf-8", + ) + target = f"rustup target add test-target --toolchain {version}" + (workflows_path / "build-baseline.yml").write_text( + "jobs:\n" + + "\n".join( + _job(job_name, install, target) + for job_name in ( + "build-windows-native", + "build-windows-arm64", + "build-macos-native", + "build-macos-arm64", + ) + ) + + "\n", + encoding="utf-8", + ) + + monkeypatch.setattr(verifier, "RUST_TOOLCHAIN", toolchain_path) + monkeypatch.setattr(verifier, "DEPENDABOT", dependabot_path) + monkeypatch.setattr(verifier, "WORKFLOWS", workflows_path) + + assert verifier.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "Rust toolchain lane is missing" in captured.err + assert "directory" in captured.err + assert "target-branch" in captured.err + assert "interval" in captured.err diff --git a/services/analysis-engine/tests/test_rust_toolchain_policy_execution.py b/services/analysis-engine/tests/test_rust_toolchain_policy_execution.py new file mode 100644 index 000000000..325c6bf3a --- /dev/null +++ b/services/analysis-engine/tests/test_rust_toolchain_policy_execution.py @@ -0,0 +1,140 @@ +"""Regression tests for executable Rust toolchain policy evidence.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import load_module + + +def _job(name: str, *commands: str) -> str: + """Return one minimal workflow job with executable run steps.""" + lines = [f" {name}:", " steps:"] + lines.extend(f" - run: {command}" for command in commands) + return "\n".join(lines) + + +def test_rust_toolchain_policy_rejects_required_command_present_only_in_comment( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A YAML comment cannot satisfy a job's compiler-install evidence.""" + verifier = load_module( + "scripts/checks/verify_rust_toolchain.py", + "verify_rust_toolchain_nonexecuting_evidence", + ) + version = verifier.EXPECTED_TOOLCHAIN + toolchain_path = tmp_path / "rust-toolchain.toml" + dependabot_path = tmp_path / ".github" / "dependabot.yml" + workflows_path = tmp_path / ".github" / "workflows" + dependabot_path.parent.mkdir(parents=True, exist_ok=True) + workflows_path.mkdir(parents=True, exist_ok=True) + + toolchain_path.write_text( + f'[toolchain]\nchannel = "{version}"\nprofile = "minimal"\n', + encoding="utf-8", + ) + dependabot_path.write_text( + "version: 2\n" + "updates:\n" + ' - package-ecosystem: "rust-toolchain"\n' + ' directory: "/"\n' + ' target-branch: "develop"\n' + " schedule:\n" + ' interval: "weekly"\n', + encoding="utf-8", + ) + + install = f"rustup toolchain install {version} --profile minimal" + (workflows_path / "ci.yml").write_text( + "jobs:\n" + " verify:\n" + " steps:\n" + f" # {install}\n" + " - run: echo no-rust-toolchain-install\n" + + _job( + "rust-check", + install, + f"cargo +{version} check", + f"cargo +{version} test", + ) + + "\n", + encoding="utf-8", + ) + (workflows_path / "release.yml").write_text( + "jobs:\n" + _job("release-preflight", install) + "\n", + encoding="utf-8", + ) + (workflows_path / "security-audit.yml").write_text( + "jobs:\n" + + _job( + "security-backstop", + install, + f"cargo +{version} install cargo-audit --locked", + f"cargo +{version} audit", + ) + + "\n", + encoding="utf-8", + ) + target = f"rustup target add test-target --toolchain {version}" + (workflows_path / "build-baseline.yml").write_text( + "jobs:\n" + + "\n".join( + _job(job_name, install, target) + for job_name in ( + "build-windows-native", + "build-windows-arm64", + "build-macos-native", + "build-macos-arm64", + ) + ) + + "\n", + encoding="utf-8", + ) + + monkeypatch.setattr(verifier, "RUST_TOOLCHAIN", toolchain_path) + monkeypatch.setattr(verifier, "DEPENDABOT", dependabot_path) + monkeypatch.setattr(verifier, "WORKFLOWS", workflows_path) + + assert verifier.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "ci.yml job 'verify' is missing" in captured.err + assert install in captured.err + + +def test_rust_toolchain_policy_rejects_failure_masking_shell_suffixes() -> None: + """Shell control flow cannot turn a failed required Rust command into evidence.""" + verifier = load_module( + "scripts/checks/verify_rust_toolchain.py", + "verify_rust_toolchain_failure_masking", + ) + version = verifier.EXPECTED_TOOLCHAIN + install = f"rustup toolchain install {version} --profile minimal" + check = f"cargo +{version} check" + test = f"cargo +{version} test" + + masked_commands = ( + (install, f"{install} || true"), + (check, f"{check} --manifest-path crate/Cargo.toml --locked | cat"), + (test, f"{test} --manifest-path crate/Cargo.toml --locked ; true"), + ) + for required, masked in masked_commands: + assert not verifier._job_runs_required_command(_job("owner", masked), required) + + +def test_rust_toolchain_policy_requires_desktop_core_integration_suite() -> None: + """The required Rust CI lane must execute desktop-core integration tests.""" + verifier = load_module( + "scripts/checks/verify_rust_toolchain.py", + "verify_rust_toolchain_core_integration_requirement", + ) + version = verifier.EXPECTED_TOOLCHAIN + requirements = verifier._required_workflow_jobs()["ci.yml"]["rust-check"] + + assert ( + f"cargo +{version} test --manifest-path apps/desktop/core/Cargo.toml --locked" + in requirements + ) diff --git a/services/analysis-engine/tests/test_rust_toolchain_policy_nested_run_evidence.py b/services/analysis-engine/tests/test_rust_toolchain_policy_nested_run_evidence.py new file mode 100644 index 000000000..ce982dbad --- /dev/null +++ b/services/analysis-engine/tests/test_rust_toolchain_policy_nested_run_evidence.py @@ -0,0 +1,27 @@ +"""Regression tests for non-executable run-shaped Rust toolchain evidence.""" + +from __future__ import annotations + +from conftest import load_module + + +def test_rust_toolchain_policy_rejects_required_command_in_nested_env_run_key() -> None: + """A nested ``env.run`` value cannot satisfy executable Rust evidence.""" + verifier = load_module( + "scripts/checks/verify_rust_toolchain.py", + "verify_rust_toolchain_nested_run_evidence", + ) + version = verifier.EXPECTED_TOOLCHAIN + install = f"rustup toolchain install {version} --profile minimal" + job = "\n".join( + ( + " owner:", + " steps:", + " - name: Pretend evidence", + " env:", + f" run: {install}", + " run: echo no-rust-toolchain-install", + ) + ) + + assert not verifier._job_runs_required_command(job, install) diff --git a/services/analysis-engine/tests/test_rust_toolchain_supply_chain_contract.py b/services/analysis-engine/tests/test_rust_toolchain_supply_chain_contract.py new file mode 100644 index 000000000..bcf7641d6 --- /dev/null +++ b/services/analysis-engine/tests/test_rust_toolchain_supply_chain_contract.py @@ -0,0 +1,73 @@ +"""Supply-chain regressions for the repository-pinned Rust audit toolchain.""" + +from __future__ import annotations + +from pathlib import Path + +from conftest import load_module + +PINNED_RUST_AUDIT = "cargo +1.97.1 audit" + + +def _security_audit_workflow(rust_audit_command: str) -> str: + """Return the smallest blocking workflow that exercises all audit families.""" + return f""" +name: security-audit +on: + pull_request: + branches: [develop, main] + push: + branches: [develop, main] +jobs: + audit: + runs-on: ubuntu-latest + steps: + - run: npm audit --workspaces --audit-level=high + - run: pip-audit --local --strict + - run: {rust_audit_command} +""".strip() + + +def _rust_audit_violations( + monkeypatch, + tmp_path: Path, + rust_audit_command: str, +) -> list[str]: + """Run only the security-audit coverage verifier against one isolated workflow.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + f"verify_supply_chain_rust_audit_{rust_audit_command.replace(' ', '_').replace('+', '')}", + ) + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "security-audit.yml").write_text( + _security_audit_workflow(rust_audit_command), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + violations: list[str] = [] + supply_chain._verify_security_audit_coverage(violations) + return violations + + +def test_supply_chain_accepts_repository_pinned_rust_audit( + monkeypatch, + tmp_path: Path, +) -> None: + """The pinned audit command must satisfy the security workflow contract.""" + violations = _rust_audit_violations(monkeypatch, tmp_path, PINNED_RUST_AUDIT) + + assert not any("missing vulnerability audit token" in item for item in violations) + + +def test_supply_chain_rejects_floating_stable_rust_audit( + monkeypatch, + tmp_path: Path, +) -> None: + """A floating stable selector cannot satisfy the exact Rust audit contract.""" + violations = _rust_audit_violations(monkeypatch, tmp_path, "cargo +stable audit") + + assert ( + "security audit workflow missing vulnerability audit token: " + PINNED_RUST_AUDIT + ) in violations diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py index 6a0853944..76abfa4dd 100644 --- a/services/analysis-engine/tests/test_supply_chain_policy.py +++ b/services/analysis-engine/tests/test_supply_chain_policy.py @@ -612,7 +612,7 @@ def test_security_audit_workflow_keeps_dependency_vulnerability_scans() -> None: assert "npm audit --workspaces --audit-level=high" in workflow assert "pip-audit --local --strict" in workflow - assert "cargo +stable audit" in workflow + assert "cargo +1.97.1 audit" in workflow def test_supply_chain_check_requires_audit_tokens_in_run_steps( @@ -642,7 +642,7 @@ def test_supply_chain_check_requires_audit_tokens_in_run_steps( run: | true # npm audit --workspaces --audit-level=high # pip-audit --local --strict - printf '%s\n' "cargo +stable audit" + printf '%s\n' "cargo +1.97.1 audit" """.strip(), encoding="utf-8", ) @@ -659,7 +659,7 @@ def test_supply_chain_check_requires_audit_tokens_in_run_steps( "security audit workflow missing vulnerability audit token: pip-audit --local --strict" ) in violations assert ( - "security audit workflow missing vulnerability audit token: cargo +stable audit" + "security audit workflow missing vulnerability audit token: cargo +1.97.1 audit" ) in violations @@ -689,7 +689,7 @@ def test_supply_chain_check_accepts_nested_shell_audit_commands( - name: Nested Python audit run: sh -ec 'pip-audit --local --strict' - name: Nested Rust audit - run: /bin/bash -c 'cargo +stable audit' + run: /bin/bash -c 'cargo +1.97.1 audit' """.strip(), encoding="utf-8", ) @@ -727,7 +727,7 @@ def test_supply_chain_check_rejects_noop_audit_command_spoofs( - name: Spoof Python audit run: : pip-audit --local --strict - name: Spoof Rust audit - run: : cargo +stable audit + run: : cargo +1.97.1 audit """.strip(), encoding="utf-8", ) @@ -744,7 +744,7 @@ def test_supply_chain_check_rejects_noop_audit_command_spoofs( "security audit workflow missing vulnerability audit token: pip-audit --local --strict" ) in violations assert ( - "security audit workflow missing vulnerability audit token: cargo +stable audit" + "security audit workflow missing vulnerability audit token: cargo +1.97.1 audit" ) in violations @@ -777,7 +777,7 @@ def test_supply_chain_check_requires_blocking_audit_steps( run: pip-audit --local --strict - name: Non-blocking Rust audit continue-on-error: true - run: cargo +stable audit + run: cargo +1.97.1 audit """.strip(), encoding="utf-8", ) @@ -794,7 +794,7 @@ def test_supply_chain_check_requires_blocking_audit_steps( "security audit workflow missing vulnerability audit token: pip-audit --local --strict" ) in violations assert ( - "security audit workflow missing vulnerability audit token: cargo +stable audit" + "security audit workflow missing vulnerability audit token: cargo +1.97.1 audit" ) in violations @@ -827,7 +827,7 @@ def test_supply_chain_check_requires_unconditional_audit_steps( run: pip-audit --local --strict - name: Skipped Rust audit if: github.ref == 'refs/heads/not-used' - run: cargo +stable audit + run: cargo +1.97.1 audit """.strip(), encoding="utf-8", ) @@ -844,7 +844,7 @@ def test_supply_chain_check_requires_unconditional_audit_steps( "security audit workflow missing vulnerability audit token: pip-audit --local --strict" ) in violations assert ( - "security audit workflow missing vulnerability audit token: cargo +stable audit" + "security audit workflow missing vulnerability audit token: cargo +1.97.1 audit" ) in violations @@ -877,7 +877,7 @@ def test_supply_chain_check_accepts_explicit_false_continue_on_error_audit_steps run: pip-audit --local --strict - name: Blocking Rust audit continue-on-error: ${{ false }} - run: cargo +stable audit + run: cargo +1.97.1 audit """.strip(), encoding="utf-8", )