Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 35 additions & 16 deletions run_codeql/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,35 +15,53 @@

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")
if which:
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}")
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion run_codeql/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Shared configuration and defaults for run_codeql."""

import os
import platform
from pathlib import Path


Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions tests/features/codeql_download.feature
Original file line number Diff line number Diff line change
@@ -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"
65 changes: 65 additions & 0 deletions tests/steps/test_codeql_download.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 24 additions & 1 deletion tests/test_download_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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"