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 abec57b6b..b6d650c26 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: | @@ -146,10 +146,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: | @@ -256,10 +256,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: Activate and verify pinned npm runtime @@ -318,10 +318,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: Activate and verify pinned npm runtime diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d17468129..92158fcd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,8 +68,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" @@ -99,13 +99,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 34583b414..f56806faa 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 + 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 f6737f1f6..224684ba1 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -49,10 +49,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 0b6f7e784..481e27c5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Changed - Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata. +- 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. ### Fixed @@ -74,4 +75,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). 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/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 000000000..bfb5c3d11 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,103 @@ +# BandScope product and technical gap baseline + +Status: **Proposed / live engineering baseline** + +This document records the current product/technical gap state needed to continue BandScope work without reconstructing repository intent from pull-request history alone. It is descriptive evidence, not a substitute for exact-head GitHub Checks, reviews, or protected-branch rules. + +## Evidence boundary + +Baseline evidence for this update was re-fetched from protected `develop@749511c3ad4000090048718f685c6bee6b3d2c25` and canonical Rust-toolchain PR #944 at parent repair head `762cdfec70df2be27f38d263bf9a4e0c6a6063c0`. The PR was reconciled by ordinary two-parent history so that this protected `develop` revision is an ancestor; no force-push or destructive rebase is part of the repair. `AGENTS.md`, `ARCHITECTURE.md`, `CHANGELOG.md`, repository workflows, the Rust doctoring record, active rulesets, and current PR evidence are the primary repository sources for this slice. + +## Buyer PRD + +BandScope is a local-first rehearsal-preparation product. The buyer-visible responsibility is to turn a song into actionable rehearsal guidance organized as `song -> section -> role`, with local analysis, secure desktop execution, playable-range/cue/harmony evidence, manual provenance-aware overrides, and exportable rehearsal artifacts. Current protected `develop` already names the first playable range for a rehearsal section so a player can verify instrument fit before rehearsal instead of inferring it from raw analysis output. + +The current platform gap addressed by #944 is evidence reproducibility: a floating Rust compiler can change numerical/native-build, security-audit, release-preflight, and packaged-desktop evidence without a repository change. The buyer requirement is therefore that one reviewed Rust compiler revision owns all repository Rust execution surfaces and that policy tests prove the required command really executes rather than appearing only in comments, labels, unrelated jobs, or failure-masked shell text. + +## TRD and runtime boundary + +BandScope uses a Tauri desktop shell with a TypeScript frontend and a local Python analysis-engine process. Repository-owned native/Rust execution is pinned through root `rust-toolchain.toml` and is checked by repository policy. Node dependency consumption is independently pinned through the approved npm runtime/lock-generator contract already integrated on protected `develop`. + +For #944, Rust `1.97.1` is the repository build baseline across ordinary CI, release preflight, dependency audit, Tauri validation, and Windows/macOS amd64/arm64 packaging. The policy verifier rejects floating selectors and evidence borrowed from other jobs/workflows or non-executing YAML. Required commands may use normal arguments such as `--locked`, explicit targets, and `--manifest-path`, but shell control flow must not mask their exit status. + +## DDD context map + +```mermaid +flowchart LR + Intake[Audio Intake Context] --> Analysis[Analysis Context] + Analysis --> Rehearsal[Rehearsal Planning Context] + Rehearsal --> Export[Rehearsal Export Context] + Desktop[Desktop Runtime Context] --> Intake + Desktop --> Rehearsal + Build[Build and Release Evidence Context] --> Desktop + Build --> Analysis +``` + +### Ubiquitous language + +- **Song**: the rehearsal source aggregate root presented to the player. +- **Section**: a bounded musical span inside a song. +- **Role**: the rehearsal responsibility/part evaluated inside a section. +- **Playable range**: validated pitch-span evidence used to tell a player whether a section fits their instrument/part. +- **Rehearsal roadmap**: player-facing sequence of section/role guidance. +- **Build evidence**: exact-revision CI/release/security result proving the product was built and tested under the reviewed toolchain. +- **Rust toolchain baseline**: the single reviewed Rust compiler revision required by repository-owned Rust jobs. + +### Aggregates, entities, value objects, services, repositories, events, invariants + +The primary rehearsal aggregate is Song with Section children and role-specific analysis/rehearsal evidence. Validated playable ranges are value-like evidence: malformed, non-pitch, or inverted spans fail closed before player-facing guidance. Analysis orchestration is a domain/application service behind the desktop IPC boundary. Local project persistence remains local-first and provenance-aware; this Rust-toolchain slice does not change persistence format, SQL schema, or cross-service data ownership. + +Relevant invariants for the current repair are: every repository-owned Rust build/audit/release job uses the reviewed toolchain; no floating `stable` selector may silently become authoritative build evidence; each Rust-owning job executes its own required command; and a required command's failure cannot be hidden by shell chaining/pipelines/background control flow. + +## UML / execution view + +```mermaid +sequenceDiagram + participant Dev as Protected develop + participant PR as Canonical PR head + participant Policy as Rust policy verifier + participant CI as GitHub Actions + participant Artifact as Desktop/release artifact + + Dev->>PR: ordinary non-force reconciliation + PR->>Policy: verify rust-toolchain + workflow execution evidence + Policy-->>CI: fail closed on drift or spoofed evidence + CI->>CI: build/test/audit with Rust 1.97.1 + CI-->>Artifact: publish only through release-gated workflow +``` + +## ERD / persistence assessment + +No database schema change is part of #944. The repair changes repository build/release policy, workflow execution, tests, doctoring, and the toolchain manifest only. Therefore there is no migration, FK/index/constraint/sequence/view change, ORM remap, UPSERT change, partition change, lock/read-write-topology change, or rollback data transform to validate in this slice. Any future persistence change must add its ERD and migration/rollback evidence here before merge. + +## Organization naming-contract status + +The touched #944 surfaces use semantically specific owned names such as `rust_toolchain`, `toolchain_channel`, build/release job names, and dedicated policy-verifier names. External GitHub Actions and tool/vendor contract keys remain unchanged at their required boundary. This slice did not identify a safe organization-owned generic one-word identifier in the touched canonical Rust-policy surface that warranted an additional rename; naming work must continue only where bounded-context ownership and consumer propagation are clear. + +## Current gap and action ledger + +| Gap / blocker | Owner | Action | Current status | +| --- | --- | --- | --- | +| Floating or inconsistently selected Rust compiler can invalidate reproducible native/security/release evidence | `ContextualWisdomLab/bandscope` #944 | Pin Rust 1.97.1 across all Rust-owning jobs and verify executable evidence | Repaired on canonical Draft PR; fresh exact-head verification required | +| Seven supply-chain policy tests encoded obsolete `cargo +stable audit` despite the intended 1.97.1 contract | `ContextualWisdomLab/bandscope` #944 | Update only stale fixture/assertion commands while preserving intentional floating-selector rejection tests | Repaired at `ba4e7c1508261f7478e16810f67e1b0b44768a8b`; temporary self-fix workflow/script removed | +| PR #944 had fallen behind protected `develop`, overlapping npm/runtime/workflow improvements and buyer-visible first-playable-range work | `ContextualWisdomLab/bandscope` #944 | Re-fetch both heads, reconcile overlapping files by intent, append ordinary two-parent commit | Repaired at `762cdfec70df2be27f38d263bf9a4e0c6a6063c0`; compare was 76 ahead / 0 behind before this documentation commit | +| JavaScript dependency/security baseline | `ContextualWisdomLab/bandscope` #783 | Land canonical npm/PDF.js/Nanoid/Undici and lock-generator protections | Merged as `7ad56cf0065d068ec6463d92726de4855a6e201d`; inherited by current protected base | +| Independent review and required workflows | protected repository/organization rulesets | Obtain fresh exact-head terminal-success checks and qualifying non-author last-push approval; resolve all review threads | Open merge gate; no bypass permitted | + +## Security, test, and operability baseline + +Protected default-branch rules require a pull request, at least one approving review, stale-review dismissal on push, last-push approval, review-thread resolution, and organization-required workflows. Organization central workflows include OpenCode review, merge scheduling, security scan, Strix, Semgrep, Noema review, CodeQL, Scorecard, and OSV scanning. Non-fast-forward updates and branch deletion are prohibited by active rulesets. + +Repository verification for #944 includes frozen npm lock validation, frontend/Python/native checks, Rust policy tests, build matrices, release preflight, dependency audits, security/SAST/SBOM/supply-chain coverage, and central required workflows. Only results attached to the unchanged current head count as merge evidence. A queued, failed, cancelled, skipped-required, predecessor-head, base-only, self-approved, or administratively bypassed result is non-passing. + +## UX, Storybook, Figma, screenshot and i18n evidence + +This Rust-toolchain repair does not modify user-interface components, design tokens, translations, or interaction states. It therefore does not manufacture Storybook/Figma/screenshot evidence for unchanged UI. Buyer-visible first-playable-range behavior is inherited from protected `develop`; its UI regression evidence remains owned by the merged product change that introduced it. Future UI changes must record normal/loading/empty/error/permission/responsive/interaction states and locale evidence for ko/en/ja/zh/vi/es/de/fr in the owning product PR. + +## Research / standards traceability + +No new scientific or psychometric claim is introduced by this toolchain-policy slice. Evidence authority is repository-executable: exact compiler selection, workflow execution semantics, tests, checks, and protected rulesets. Scientific/music-analysis validation remains owned by the corresponding BandScope analysis/product changes and must not be inferred from this build-policy PR. When external standards or peer-reviewed claims materially change a decision, the owning doctoring/ADR must add APA 7th references and bind them to the exact module/API/experiment affected. + +## Next merge conditions + +#944 remains Draft until its newest exact head has all applicable live checks terminal-success, valid review findings and threads resolved, and a qualifying independent non-author approval current for the last push. Immediately before ordinary merge, re-fetch protected `develop`, the PR head, rulesets, reviews, threads, and required checks; if either head moved, reconcile again by intent rather than force-pushing or transferring predecessor evidence. 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..204fb5e5b --- /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": { + "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..d84f39a72 --- /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( + "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", + 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..4d5918521 --- /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( + "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) + + +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 ab43df89f..08c8ae806 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", )