From c348522ad2cf4bc10793dcccafda2adb26fe248f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 25 Apr 2026 16:39:27 -0700 Subject: [PATCH] feat: support CodeQL auto-download on Windows Made-with: Cursor --- README.md | 2 +- run_codeql/download.py | 51 +++++++++++++------- run_codeql/settings.py | 11 ++++- tests/features/codeql_download.feature | 11 +++++ tests/steps/test_codeql_download.py | 65 ++++++++++++++++++++++++++ tests/test_download_integrity.py | 25 +++++++++- 6 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 tests/features/codeql_download.feature create mode 100644 tests/steps/test_codeql_download.py diff --git a/README.md b/README.md index 34470e7..e3c8985 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This installs two commands: `run-codeql` and the shorthand `rcql`. ## Requirements - Python 3.10+ -- CodeQL CLI — auto-downloaded to `~/.codeql-tools/` on first run if not already on `PATH` (SHA-256 verified, with retry/timeout policy) +- CodeQL CLI — auto-downloaded on Linux, macOS, and Windows to `~/.codeql-tools/` on first run if not already on `PATH` (SHA-256 verified, with retry/timeout policy) ## Usage diff --git a/run_codeql/download.py b/run_codeql/download.py index ecda0f4..5ff6c4e 100644 --- a/run_codeql/download.py +++ b/run_codeql/download.py @@ -15,17 +15,36 @@ from run_codeql.logging_utils import LOGGER, err, log from run_codeql.settings import ( - CODEQL_BIN, CODEQL_VERSION, DOWNLOAD_RETRY_ATTEMPTS, DOWNLOAD_RETRY_SLEEP_SECONDS, DOWNLOAD_TIMEOUT_SECONDS, TOOLS_DIR, + codeql_bin_path, ) T = TypeVar("T") +def codeql_bundle_platform(system: str | None = None) -> str: + """Return the CodeQL bundle platform token for the current operating system.""" + resolved_system = system or platform.system() + if resolved_system == "Linux": + return "linux64" + if resolved_system == "Darwin": + return "osx64" + if resolved_system == "Windows": + return "win64" + raise ValueError(f"Unsupported platform for CodeQL auto-download: {resolved_system}") + + +def _is_usable_codeql(path: Path, system: str) -> bool: + """Return whether a downloaded CodeQL binary can be used on the target OS.""" + if system == "Windows": + return path.is_file() + return path.is_file() and os.access(path, os.X_OK) + + def fetch_codeql() -> Path: """Resolve an executable CodeQL binary, downloading and verifying if needed.""" which = shutil.which("codeql") @@ -33,17 +52,16 @@ def fetch_codeql() -> Path: log(f"Using system CodeQL: {which}") return Path(which) - if CODEQL_BIN.is_file() and os.access(CODEQL_BIN, os.X_OK): - log(f"Using downloaded CodeQL: {CODEQL_BIN}") - return CODEQL_BIN - system = platform.system() - if system == "Linux": - plat = "linux64" - elif system == "Darwin": - plat = "osx64" - else: - err(f"Unsupported platform for CodeQL auto-download: {system}") + downloaded_codeql = codeql_bin_path(system=system, tools_dir=TOOLS_DIR) + if _is_usable_codeql(downloaded_codeql, system): + log(f"Using downloaded CodeQL: {downloaded_codeql}") + return downloaded_codeql + + try: + plat = codeql_bundle_platform(system) + except ValueError as exc: + err(str(exc)) sys.exit(1) log(f"Downloading CodeQL CLI {CODEQL_VERSION} to {TOOLS_DIR}") @@ -72,13 +90,14 @@ def fetch_codeql() -> Path: finally: tmp.unlink(missing_ok=True) - if not (CODEQL_BIN.is_file() and os.access(CODEQL_BIN, os.X_OK)): - err(f"Downloaded CodeQL bundle missing binary at {CODEQL_BIN}") + if not _is_usable_codeql(downloaded_codeql, system): + err(f"Downloaded CodeQL bundle missing binary at {downloaded_codeql}") sys.exit(1) - CODEQL_BIN.chmod(CODEQL_BIN.stat().st_mode | 0o111) - log(f"Downloaded CodeQL to {CODEQL_BIN}") - return CODEQL_BIN + if system != "Windows": + downloaded_codeql.chmod(downloaded_codeql.stat().st_mode | 0o111) + log(f"Downloaded CodeQL to {downloaded_codeql}") + return downloaded_codeql def _with_retries(action: str, operation: Callable[[], T]) -> T: diff --git a/run_codeql/settings.py b/run_codeql/settings.py index dc6b707..84d12fd 100644 --- a/run_codeql/settings.py +++ b/run_codeql/settings.py @@ -1,6 +1,7 @@ """Shared configuration and defaults for run_codeql.""" import os +import platform from pathlib import Path @@ -16,9 +17,17 @@ def _int_env(name: str, default: int) -> int: return value if value > 0 else default +def codeql_bin_path(system: str | None = None, tools_dir: Path | None = None) -> Path: + """Return the downloaded CodeQL executable path for an operating system.""" + resolved_system = system or platform.system() + resolved_tools_dir = tools_dir or TOOLS_DIR + executable = "codeql.exe" if resolved_system == "Windows" else "codeql" + return resolved_tools_dir / "codeql" / executable + + CODEQL_VERSION = "2.24.2" TOOLS_DIR = Path.home() / ".codeql-tools" -CODEQL_BIN = TOOLS_DIR / "codeql" / "codeql" +CODEQL_BIN = codeql_bin_path() PACKAGES_DIR = Path.home() / ".codeql" / "packages" DOWNLOAD_TIMEOUT_SECONDS = _int_env("RCQL_DOWNLOAD_TIMEOUT_SECONDS", 60) DOWNLOAD_RETRY_ATTEMPTS = _int_env("RCQL_DOWNLOAD_RETRY_ATTEMPTS", 3) diff --git a/tests/features/codeql_download.feature b/tests/features/codeql_download.feature new file mode 100644 index 0000000..c850848 --- /dev/null +++ b/tests/features/codeql_download.feature @@ -0,0 +1,11 @@ +Feature: CodeQL auto-download + As a Windows developer running rcql locally, + I want rcql to install the matching CodeQL CLI bundle automatically, + So that I can scan repositories without manually installing CodeQL first. + + Scenario: Windows auto-download uses the Windows bundle and executable + Given no CodeQL executable is already installed + And the operating system is "Windows" + When CodeQL is resolved for rcql + Then the Windows CodeQL bundle is downloaded + And the resolved CodeQL executable is "codeql.exe" diff --git a/tests/steps/test_codeql_download.py b/tests/steps/test_codeql_download.py new file mode 100644 index 0000000..78b9bb5 --- /dev/null +++ b/tests/steps/test_codeql_download.py @@ -0,0 +1,65 @@ +import hashlib +import io +import tarfile + +import pytest +from pytest_bdd import given, parsers, scenarios, then, when + +import run_codeql.download as download + +scenarios("../features/codeql_download.feature") + + +@pytest.fixture() +def download_ctx(tmp_path, monkeypatch): + tools_dir = tmp_path / ".codeql-tools" + downloaded_urls: list[str] = [] + + def fake_download_file(url, destination): + downloaded_urls.append(url) + data = b"@echo off\r\n" + tar_info = tarfile.TarInfo("codeql/codeql.exe") + tar_info.size = len(data) + with tarfile.open(destination, "w:gz") as tar: + tar.addfile(tar_info, io.BytesIO(data)) + + def fake_download_text(_url): + archive = tools_dir / "codeql-bundle-win64.tar.gz.part" + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + return f"{digest} codeql-bundle-win64.tar.gz\n" + + monkeypatch.setattr(download, "TOOLS_DIR", tools_dir) + monkeypatch.setattr(download.shutil, "which", lambda _name: None) + monkeypatch.setattr(download, "download_file_with_retry", fake_download_file) + monkeypatch.setattr(download, "download_text_with_retry", fake_download_text) + + return { + "downloaded_urls": downloaded_urls, + "result": None, + } + + +@given("no CodeQL executable is already installed") +def no_codeql_executable(download_ctx): + assert download_ctx["result"] is None + + +@given(parsers.parse('the operating system is "{system}"')) +def operating_system(download_ctx, monkeypatch, system): + monkeypatch.setattr(download.platform, "system", lambda: system) + + +@when("CodeQL is resolved for rcql") +def resolve_codeql(download_ctx): + download_ctx["result"] = download.fetch_codeql() + + +@then("the Windows CodeQL bundle is downloaded") +def windows_bundle_downloaded(download_ctx): + assert download_ctx["downloaded_urls"] + assert download_ctx["downloaded_urls"][0].endswith("/codeql-bundle-win64.tar.gz") + + +@then(parsers.parse('the resolved CodeQL executable is "{name}"')) +def resolved_executable_name(download_ctx, name): + assert download_ctx["result"].name == name diff --git a/tests/test_download_integrity.py b/tests/test_download_integrity.py index 2ee4d0f..bbb4c63 100644 --- a/tests/test_download_integrity.py +++ b/tests/test_download_integrity.py @@ -2,7 +2,8 @@ import pytest -from run_codeql.download import compute_sha256, parse_sha256_checksum +from run_codeql.download import codeql_bundle_platform, compute_sha256, parse_sha256_checksum +from run_codeql.settings import codeql_bin_path def test_parse_sha256_checksum_finds_expected_file(): @@ -28,3 +29,25 @@ def test_compute_sha256(tmp_path: Path): assert ( compute_sha256(target) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ) + + +@pytest.mark.parametrize( + ("system", "platform_token"), + [ + ("Linux", "linux64"), + ("Darwin", "osx64"), + ("Windows", "win64"), + ], +) +def test_codeql_bundle_platform(system: str, platform_token: str): + assert codeql_bundle_platform(system) == platform_token + + +def test_codeql_bin_path_uses_windows_executable(tmp_path: Path): + assert ( + codeql_bin_path(system="Windows", tools_dir=tmp_path) == tmp_path / "codeql" / "codeql.exe" + ) + + +def test_codeql_bin_path_uses_unix_executable(tmp_path: Path): + assert codeql_bin_path(system="Linux", tools_dir=tmp_path) == tmp_path / "codeql" / "codeql"