diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a469742..eb1f447 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -37,12 +37,19 @@ jobs: - name: Verify runner toolchain run: | + python3 --version rustc --version cargo --version rustfmt --version cargo clippy --version docker --version + - name: Run tooling unit tests + run: python3 -m unittest discover -s tests/unit + + - name: Check Community scope + run: scripts/check-community-scope.sh + - name: Check formatting run: cargo fmt --all --check diff --git a/.gitignore b/.gitignore index 159d57a..6bf7173 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ apps/ui/test-results apps/ui/vite.config.js apps/ui/vite.config.d.ts *.log +__pycache__/ __*.md diploma/ AGENTS.md diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 79da005..f286413 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -164,11 +164,7 @@ mod tests { use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*}; use crank_community_mcp::{ - auth::{ - CommunityMachineCredentialVerifier, MachineCredentialVerifier, - MachineCredentialVerifierError, SharedMachineCredentialVerifier, - VerifiedMachineCredential, - }, + auth::{CommunityMachineCredentialVerifier, SharedMachineCredentialVerifier}, build_app, catalog::PublishedToolCatalog, session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore}, @@ -272,28 +268,6 @@ mod tests { ) } - #[derive(Clone)] - struct StubMachineCredentialVerifier { - token: String, - credential: VerifiedMachineCredential, - } - - #[async_trait::async_trait] - impl MachineCredentialVerifier for StubMachineCredentialVerifier { - async fn verify_bearer_token( - &self, - _workspace_slug: &str, - _agent_slug: &str, - token: &str, - ) -> Result, MachineCredentialVerifierError> { - if token == self.token { - return Ok(Some(self.credential.clone())); - } - - Ok(None) - } - } - #[tokio::test] async fn initializes_lists_and_calls_published_tool_via_mcp() { let registry = test_registry().await; diff --git a/justfile b/justfile index 984fb1b..018d7b2 100644 --- a/justfile +++ b/justfile @@ -4,6 +4,12 @@ fmt: fmt-check: cargo fmt --all --check +tooling-test: + python3 -m unittest discover -s tests/unit + +community-scope-check: + scripts/check-community-scope.sh + check: cargo check --workspace @@ -20,6 +26,8 @@ sqlx-check: SQLX_OFFLINE=true cargo check --workspace verify: + just tooling-test + just community-scope-check just fmt-check just clippy just test diff --git a/scripts/README.md b/scripts/README.md index d465aca..c0ea0a7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -71,3 +71,18 @@ shell env file. ```bash ./scripts/check-rust-code-health.sh ``` + +## `check-community-scope.sh` + +Проверяет, что в community-репозиторий не попали функции и тексты за пределами +REST -> MCP сценария. + +```bash +./scripts/check-community-scope.sh +``` + +Unit-тесты checker-а лежат в `tests/unit`: + +```bash +python3 -m unittest discover -s tests/unit +``` diff --git a/scripts/check-community-scope.py b/scripts/check-community-scope.py new file mode 100755 index 0000000..09bd9f1 --- /dev/null +++ b/scripts/check-community-scope.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +import argparse +import re +import subprocess +import sys +from pathlib import Path + + +DEFAULT_EXCLUDED_PATHS = { + "LICENSE", + "Cargo.lock", + "apps/ui/package-lock.json", + "scripts/check-community-scope.py", + "tests/unit/test_check_community_scope.py", +} + +DEFAULT_EXCLUDED_PREFIXES = { + "target/", + "apps/ui/node_modules/", + "apps/ui/dist/", +} + +FORBIDDEN_PATTERNS = [ + ("enterprise", re.compile(r"\benterprise\b", re.IGNORECASE)), + ("cloud", re.compile(r"\bcloud\b", re.IGNORECASE)), + ("commercial", re.compile(r"\bcommercial\b|коммер", re.IGNORECASE)), + ("cloud-russian", re.compile(r"облач", re.IGNORECASE)), + ("graphql", re.compile(r"\bgraphql\b", re.IGNORECASE)), + ("grpc", re.compile(r"\bgrpc\b", re.IGNORECASE)), + ("soap", re.compile(r"\bsoap\b", re.IGNORECASE)), + ("websocket", re.compile(r"\bwebsocket\b", re.IGNORECASE)), + ("short-lived-token", re.compile(r"short[-_ ]?lived", re.IGNORECASE)), + ("one-time-token", re.compile(r"one[-_ ]?time", re.IGNORECASE)), + ("sso", re.compile(r"\bsso\b", re.IGNORECASE)), + ("two-factor", re.compile(r"two[-_ ]?factor|\b2fa\b|\bmfa\b", re.IGNORECASE)), + ("billing", re.compile(r"\bbilling\b", re.IGNORECASE)), + ("tenant", re.compile(r"\btenant\b", re.IGNORECASE)), + ("stream-session", re.compile(r"stream session", re.IGNORECASE)), + ("async-job", re.compile(r"async job", re.IGNORECASE)), + ("mcp-auth", re.compile(r"mcp-auth", re.IGNORECASE)), + ("protocol-capabilities", re.compile(r"protocol-capabilities", re.IGNORECASE)), + ("machine-token", re.compile(r"machine token", re.IGNORECASE)), + ("token-issuer", re.compile(r"token issuer", re.IGNORECASE)), + ("request-variables", re.compile(r"request\.variables", re.IGNORECASE)), +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Fail if Community repository contains out-of-scope product markers." + ) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="Repository root. Defaults to parent of scripts/.", + ) + parser.add_argument( + "--files", + nargs="*", + help="Optional explicit file list, relative to root. Defaults to git ls-files.", + ) + return parser.parse_args() + + +def git_tracked_files(root: Path) -> list[str]: + result = subprocess.run( + ["git", "ls-files"], + cwd=root, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return [line for line in result.stdout.splitlines() if line] + + +def should_skip_path(path: str) -> bool: + if path in DEFAULT_EXCLUDED_PATHS: + return True + return any(path.startswith(prefix) for prefix in DEFAULT_EXCLUDED_PREFIXES) + + +def is_binary(data: bytes) -> bool: + return b"\0" in data[:4096] + + +def scan_file(root: Path, relative_path: str) -> list[str]: + if should_skip_path(relative_path): + return [] + + path = root / relative_path + if not path.is_file(): + return [] + + data = path.read_bytes() + if is_binary(data): + return [] + + text = data.decode("utf-8", errors="replace") + findings: list[str] = [] + for line_no, line in enumerate(text.splitlines(), start=1): + for label, pattern in FORBIDDEN_PATTERNS: + if pattern.search(line): + findings.append(f"{relative_path}:{line_no}: forbidden marker `{label}`") + return findings + + +def main() -> int: + args = parse_args() + root = args.root.resolve() + files = args.files if args.files is not None else git_tracked_files(root) + findings: list[str] = [] + + for relative_path in sorted(files): + findings.extend(scan_file(root, relative_path)) + + if findings: + print("Community scope check failed:", file=sys.stderr) + for finding in findings: + print(f"error: {finding}", file=sys.stderr) + return 1 + + print(f"Community scope check passed ({len(files)} tracked files scanned)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-community-scope.sh b/scripts/check-community-scope.sh new file mode 100755 index 0000000..cd494ba --- /dev/null +++ b/scripts/check-community-scope.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +python3 "$ROOT_DIR/scripts/check-community-scope.py" --root "$ROOT_DIR" diff --git a/tests/unit/test_check_community_scope.py b/tests/unit/test_check_community_scope.py new file mode 100644 index 0000000..f8195cc --- /dev/null +++ b/tests/unit/test_check_community_scope.py @@ -0,0 +1,73 @@ +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CHECKER = ROOT / "scripts" / "check-community-scope.py" + + +class CommunityScopeCheckTests(unittest.TestCase): + def run_checker(self, root: Path, files: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(CHECKER), "--root", str(root), "--files", *files], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def test_passes_for_clean_community_content(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "README.md").write_text( + "Crank publishes REST API endpoints as MCP tools.\n", + encoding="utf-8", + ) + + result = self.run_checker(root, ["README.md"]) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Community scope check passed", result.stdout) + + def test_fails_for_forbidden_product_marker(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "apps").mkdir() + (root / "apps" / "ui.md").write_text( + "Hidden GraphQL protocol card must not be here.\n", + encoding="utf-8", + ) + + result = self.run_checker(root, ["apps/ui.md"]) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("apps/ui.md:1", result.stderr) + self.assertIn("graphql", result.stderr.lower()) + + def test_ignores_default_excluded_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "LICENSE").write_text( + "The license text may mention commercial distribution terms.\n", + encoding="utf-8", + ) + + result = self.run_checker(root, ["LICENSE"]) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_skips_binary_files(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "logo.png").write_bytes(b"\x89PNG\x00enterprise\x00") + + result = self.run_checker(root, ["logo.png"]) + + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main()