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 552f6d69d..b8ba47185 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -47,10 +47,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 + 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: | @@ -142,10 +142,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 + 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: | @@ -248,10 +248,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 + 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: Install node dependencies @@ -306,10 +306,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 + 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: Install node dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f99a9c17..ce46c2265 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,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" @@ -58,13 +58,13 @@ jobs: with: node-version: 22.22.3 cache: npm - - 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84ace55d4..e5cd7a722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,8 +40,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 + run: rustup toolchain install 1.97.1 --profile minimal - name: Install node dependencies run: npm ci - name: Sync Python dependencies diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 7d880c1a1..59865a163 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -43,10 +43,10 @@ jobs: run: uv sync --project services/analysis-engine --group dev --frozen - name: Audit Python dependencies run: uv run --project services/analysis-engine --with pip-audit==2.8.0 pip-audit --local --strict - - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..eec6bfe64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Changed + +- Pin repository-owned Rust build, audit, release, and native packaging evidence to Rust 1.97.1, and fail closed when required Rust commands are hidden in non-executing text, borrowed from another workflow/job, or chained through shell control flow that can mask their exit status. + ## [0.1.3] - 2026-04-29 ### Fixed 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..99d733ac1 --- /dev/null +++ b/scripts/checks/verify_rust_toolchain.py @@ -0,0 +1,234 @@ +#!/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", + ), + }, + "release.yml": {"release-preflight": (install,)}, + "security-audit.yml": { + "audit": ( + 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 1cd561e5c..eccd4352e 100644 --- a/scripts/checks/verify_supply_chain.py +++ b/scripts/checks/verify_supply_chain.py @@ -142,6 +142,7 @@ "glib", ) RUST_FASTRAND_YANKED_VERSION = "2.4.0" +RUST_AUDIT_COMMAND = "cargo +1.97.1 audit" RUST_AUDIT_CONFIG = Path("apps/desktop/src-tauri/.cargo/audit.toml") RUST_OSV_SCANNER_CONFIG = Path("apps/desktop/src-tauri/osv-scanner.toml") TRIVY_IGNORE_CONFIG = Path(".trivyignore") @@ -1232,7 +1233,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", + RUST_AUDIT_COMMAND, ]: 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_rust_toolchain_policy.py b/services/analysis-engine/tests/test_rust_toolchain_policy.py new file mode 100644 index 000000000..f42d7ddb0 --- /dev/null +++ b/services/analysis-engine/tests/test_rust_toolchain_policy.py @@ -0,0 +1,287 @@ +"""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( + "audit", + 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", + ) + + "\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..944f7792f --- /dev/null +++ b/services/analysis-engine/tests/test_rust_toolchain_policy_execution.py @@ -0,0 +1,125 @@ +"""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( + "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 "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) 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