Files
trailofbits-buttercup/program-model/src/buttercup/program_model/codequery.py
T
Henrik Brodin 1e4e56490f NodeLocalStorage (#369)
This PR introduces a NodeLocal storage concept. Data is always processed on the local node, and only replicated to the azure file storage as needed.

A DaemonSet is introduced that syncs any corpus on the local node with the global corpus directory.

The following path structure is introduced:

/node_data/crs_scratch
/node_data/tasks_storage
/node_data/scratch
The first two directories is the equivalent of the previous usage of

/crs_scratch
/tasks_storage
The scratch is for basing temporary files and directories on the same filesystem as node-local version to be able to do atomic renames.

Further, ChallengeTask data is used normally on the /node_data/... paths, but is stored as /tasks_storage/{uuid}.tgz on the "remote" storage. This is to speed up copying.

Corpus and CrashSets are synced as is.
2025-04-11 14:03:49 +02:00

474 lines
17 KiB
Python

"""Codequery based code querying module"""
from __future__ import annotations
import logging
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from itertools import groupby
from typing import ClassVar, Union
from buttercup.common.challenge_task import ChallengeTask, ChallengeTaskError
from buttercup.program_model.api.tree_sitter import CodeTS
from buttercup.program_model.utils.common import (
Function,
TypeDefinition,
)
from buttercup.common.project_yaml import ProjectYaml
logger = logging.getLogger(__name__)
@dataclass
class CQSearchResult:
"""Result of the cqsearch command."""
value: str
file: Path
line: int
body: str
@classmethod
def from_line(cls, line: str, base_path: Path) -> CQSearchResult | None:
"""Parse a line of the cqsearch output into a CQSearchResult."""
try:
value, file_line, body = line.split("\t", 2)
file, line = file_line.split(":", 1)
except ValueError:
logger.warning("Invalid cqsearch line: %s", line)
return None
file = Path(file)
if file.is_relative_to(base_path):
file = file.relative_to(base_path)
try:
line_number = int(line)
except ValueError:
logger.warning("Invalid line number: %s", line)
line_number = 0
return cls(value, file, line_number, body)
@dataclass
class CodeQuery:
"""Class to extract context about a challenge project with CodeQuery."""
challenge: ChallengeTask
ts: CodeTS = field(init=False)
_base_path: Path = field(init=False)
BASE_PATH: ClassVar[str] = "cqdb_base_path"
CSCOPE_FILES: ClassVar[str] = "cscope.files"
CSCOPE_OUT: ClassVar[str] = "cscope.out"
TAGS: ClassVar[str] = "tags"
CODEQUERY_DB: ClassVar[str] = "codequery.db"
def __post_init__(self) -> None:
"""Initialize the CodeQuery object."""
self._verify_requirements()
self.ts = CodeTS(self.challenge)
if self._is_already_indexed():
self._base_path = Path(
self.challenge.task_dir.joinpath("cqdb_base_path").read_text()
)
logger.info("CodeQuery DB already exists in %s.", self.challenge.task_dir)
return
if self.challenge.local_task_dir is None:
raise ValueError(
"Challenge Task is read-only, cannot perform this operation"
)
self._create_codequery_db()
logger.info("CodeQuery DB created successfully.")
def _verify_requirements(self) -> None:
"""Verify that the required commands are installed."""
required_commands = ["cscope", "ctags", "cqmakedb", "cqsearch"]
missing_commands = []
for command in required_commands:
if shutil.which(command) is None:
missing_commands.append(command)
if missing_commands:
logger.fatal(
"Missing commands: %s. Please install the 'codequery' package.",
", ".join(missing_commands),
)
raise RuntimeError("No code query package")
def _is_already_indexed(self) -> bool:
"""Check if the codequery database already exists."""
return (
self.challenge.task_dir.joinpath(self.CSCOPE_FILES).exists()
and self.challenge.task_dir.joinpath(self.CSCOPE_OUT).exists()
and self.challenge.task_dir.joinpath(self.CODEQUERY_DB).exists()
and self.challenge.task_dir.joinpath(self.TAGS).exists()
and self.challenge.task_dir.joinpath(self.BASE_PATH).exists()
)
def _create_codequery_db(self) -> None:
"""Create the codequery database."""
with self.challenge.task_dir.joinpath(self.CSCOPE_FILES).open("w") as f:
project_yaml = ProjectYaml(
self.challenge, self.challenge.task_meta.project_name
)
if project_yaml.language == "c" or project_yaml.language == "c++":
extensions = [
"*.c",
"*.cpp",
"*.cxx",
"*.cc",
"*.h",
"*.hpp",
"*.hxx",
"*.hh",
]
elif project_yaml.language == "jvm":
extensions = ["*.java"]
else:
raise ValueError(f"Unsupported language: {project_yaml.language}")
# Find all files with the given extensions
# When looking at files in oss-fuzz, we filter out files that are not in the challenge
oss_fuzz_projects_dir = self.challenge.get_oss_fuzz_path() / "projects"
challenge_task_projects_dir = (
oss_fuzz_projects_dir / self.challenge.task_meta.project_name
)
for ext in extensions:
for file in self.challenge.task_dir.rglob(ext):
if file.is_relative_to(self.challenge.get_oss_fuzz_path()):
if not file.is_relative_to(challenge_task_projects_dir):
continue
f.write(str(file) + "\n")
try:
subprocess.run(["cscope", "-cb"], cwd=self.challenge.task_dir, timeout=200)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
raise RuntimeError("Failed to create cscope index.")
if not self.challenge.task_dir.joinpath(self.CSCOPE_OUT).exists():
raise RuntimeError("Failed to create cscope out.")
try:
subprocess.run(
["ctags", "--fields=+i", "-n", "-L", self.CSCOPE_FILES],
cwd=self.challenge.task_dir,
timeout=300,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
raise RuntimeError("Failed to create ctags index.")
if not self.challenge.task_dir.joinpath(self.TAGS).exists():
raise RuntimeError("Failed to create ctags index.")
try:
subprocess.run(
[
"cqmakedb",
"-s",
self.CODEQUERY_DB,
"-c",
self.CSCOPE_OUT,
"-t",
self.TAGS,
"-p",
],
cwd=self.challenge.task_dir,
timeout=2700,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
raise RuntimeError("Failed to create cquery database.")
# Save the base path to a file in the challenge task
self.challenge.task_dir.joinpath("cqdb_base_path").write_text(
self.challenge.task_dir.as_posix()
)
self._base_path = self.challenge.task_dir
def __repr__(self) -> str:
return f"CodeQuery(challenge={self.challenge})"
def _run_cqsearch(self, *args: str) -> list[CQSearchResult]:
"""Run the cqsearch command and parse the results."""
try:
logger.debug("Running cqsearch with args: %s", " ".join(args))
result = subprocess.run(
["cqsearch", *args],
cwd=self.challenge.task_dir,
capture_output=True,
text=True,
check=True,
)
output = result.stdout
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to run cqsearch: {e}")
results = [
CQSearchResult.from_line(line, self._base_path)
for line in output.splitlines()
]
return [result for result in results if result is not None]
def get_functions(
self,
function_name: str,
file_path: Path | None = None,
line_number: int | None = None,
fuzzy: bool | None = False,
) -> list[Function]:
"""Get the definition(s) of a function in the codebase or in a specific file."""
# Get symbols and functions. Some functions are not found by cqsearching functions so we have to use symbols instead.
results_all = []
for search_type in ["1", "2"]: # 1 for symbols, 2 for functions
cqsearch_args = [
"-s",
self.CODEQUERY_DB,
"-p",
search_type,
"-t",
function_name,
"-f" if fuzzy else "-e",
"-u",
]
if file_path:
cqsearch_args += ["-b", file_path.as_posix()]
results = self._run_cqsearch(*cqsearch_args)
logger.debug("cqsearch output: %s", results)
results_all.extend(results)
res: set[Function] = set()
results_by_file = groupby(results_all, key=lambda x: x.file)
for file, results in results_by_file:
functions_found = [result.value for result in results]
if not fuzzy and not all(function_name == f for f in functions_found):
logger.warning(
"Function name mismatch, this should not happen: %s",
function_name,
)
continue
if fuzzy and not all(function_name in f for f in functions_found):
logger.warning(
"Function name mismatch, this should not happen: %s",
function_name,
)
continue
for function in functions_found:
f = self.ts.get_function(function, file)
if f is None:
logger.warning("Function not found in tree-sitter: %s", function)
continue
if line_number:
lines = [body.start_line for body in f.bodies]
logger.debug(
"Looking for function %s in file %s on lines %s",
function,
file,
",".join(map(str, lines)),
)
if line_number in lines:
res.add(f)
else:
logger.warning(
"Function (%s) not found using tree-sitter in file %s on line %d",
function,
file,
line_number,
)
else:
res.add(f)
return list(res)
def get_callers(self, function: Function) -> list[Function]:
"""Get the callers of a function."""
cqsearch_args = [
"-s",
self.CODEQUERY_DB, # Specify the database file path
"-p",
"6",
"-t",
function.name,
"-e",
"-u", # use full paths
]
results = self._run_cqsearch(*cqsearch_args)
logger.debug("cqsearch output: %s", results)
callers: set[Function] = set()
for result in results:
functions = self.get_functions(result.value, Path(result.file), result.line)
callers.update(functions)
return list(callers)
def get_callees(self, function: Function) -> list[Function]:
"""Get the callees of a function."""
cqsearch_args = [
"-s",
self.CODEQUERY_DB, # Specify the database file path
"-p",
"7",
"-t",
function.name,
"-e",
"-u", # use full paths
]
results = self._run_cqsearch(*cqsearch_args)
logger.debug("cqsearch output: %s", results)
callees: set[Function] = set()
for result in results:
functions = self.get_functions(result.value, Path(result.file))
callees.update(functions)
return list(callees)
def get_types(
self,
type_name: Union[bytes, str],
file_path: Path | None = None,
function_name: str | None = None,
fuzzy: bool | None = False,
) -> list[TypeDefinition]:
"""Finds and return the definition of type named `typename`."""
cqsearch_args = [
"-s",
self.CODEQUERY_DB, # Specify the database file path
"-p",
"1", # '1' for symbol
"-t",
type_name, # The name of the type
"-f" if fuzzy else "-e",
"-u", # use full paths
]
if file_path:
cqsearch_args += ["-b", file_path.as_posix()]
results = self._run_cqsearch(*cqsearch_args)
logger.debug("cqsearch output: %s", results)
res: list[TypeDefinition] = []
results_by_file = groupby(results, key=lambda x: x.file)
for file, results in results_by_file:
types_found = [result.value for result in results]
if not fuzzy and not all(type_name == t for t in types_found):
logger.warning(
"Type name mismatch, this should not happen: %s",
type_name,
)
continue
if fuzzy and not all(type_name in t for t in types_found):
logger.warning(
"Type name mismatch, this should not happen: %s",
type_name,
)
continue
typedefs: dict[str, TypeDefinition] = {}
for typename in types_found:
t = self.ts.parse_types_in_code(file, typename, fuzzy)
if not t:
logger.warning(
"Type definition not found in tree-sitter: %s", typename
)
continue
typedefs.update(t)
if function_name:
# Get the function definition to find its scope
function = self.ts.get_function(function_name, file)
if function:
# Filter type definitions to only include those within the function's scope
filtered_typedefs = {}
for name, typedef in typedefs.items():
# Check if the type definition is within the function's scope
for body in function.bodies:
if (
body.start_line
<= typedef.definition_line
<= body.end_line
):
filtered_typedefs[name] = typedef
break
typedefs = filtered_typedefs
else:
typedefs = {}
res.extend(typedefs.values())
return res
def get_type_calls(self, type_definition: TypeDefinition) -> list[tuple[Path, int]]:
"""Get the calls to a type definition."""
cqsearch_args = [
"-s",
self.CODEQUERY_DB, # Specify the database file path
"-p",
"8",
"-t",
type_definition.name,
"-e",
"-u", # use full paths
]
results = self._run_cqsearch(*cqsearch_args)
logger.debug("cqsearch output: %s", results)
calls: list[tuple[Path, int]] = []
for result in results:
calls.append((Path(result.file), result.line))
return calls
@dataclass
class CodeQueryPersistent(CodeQuery):
"""CodeQuery that we persist the status of the db
It saves the db in the same workdir used by the challenge task, and it uses
a copy of the challenge task named with the task-id + suffix. In this way it
can always retrieve the db given a challenge task (even if it's a rw copy
used by another instance).
"""
work_dir: Path
def __post_init__(self) -> None:
"""Post init the persistent codequery db"""
task_id = self.challenge.task_meta.task_id
cqdb_path = self.work_dir.joinpath(task_id + ".cqdb")
try:
self.challenge = ChallengeTask(cqdb_path, local_task_dir=cqdb_path)
super().__post_init__()
except ChallengeTaskError:
# This is the case where the cqdb is not yet created
logger.debug("Creating new CodeQueryPersistent DB in %s", cqdb_path)
with self.challenge.get_rw_copy(self.work_dir) as persistent_challenge:
self.challenge = persistent_challenge
super().__post_init__()
try:
persistent_challenge.commit(".cqdb")
logger.debug(
f"Uploading cqdb {persistent_challenge.local_task_dir} to remote storage"
)
except Exception as e:
logger.exception("Failed to commit the cqdb: %s", e)
raise e