feat(modules): add harness-tester module for Rust fuzzing pipeline

This commit is contained in:
AFredefon
2026-02-03 18:09:14 +01:00
parent f099bd018d
commit 8b8662d7af
35 changed files with 2571 additions and 280 deletions
+112 -20
View File
@@ -322,14 +322,21 @@ class ModuleExecutor:
self,
assets_path: Path,
configuration: dict[str, Any] | None = None,
project_path: Path | None = None,
execution_id: str | None = None,
) -> Path:
"""Prepare input directory with assets and configuration.
Creates a temporary directory with input.json describing all resources.
Creates a directory with input.json describing all resources.
This directory can be volume-mounted into the container.
If assets_path is a directory, it is used directly (zero-copy mount).
If assets_path is a file (e.g., tar.gz), it is extracted first.
:param assets_path: Path to the assets (file or directory).
:param configuration: Optional module configuration dict.
:param project_path: Project directory for storing inputs in .fuzzforge/.
:param execution_id: Execution ID for organizing inputs.
:returns: Path to prepared input directory.
:raises SandboxError: If preparation fails.
@@ -339,12 +346,65 @@ class ModuleExecutor:
logger.info("preparing input directory", assets=str(assets_path))
try:
# Create temporary directory - caller must clean it up after container finishes
from tempfile import mkdtemp
# If assets_path is already a directory, use it directly (zero-copy mount)
if assets_path.exists() and assets_path.is_dir():
# Create input.json directly in the source directory
input_json_path = assets_path / "input.json"
# Scan files and build resource list
resources = []
for item in assets_path.iterdir():
if item.name == "input.json":
continue
if item.is_file():
resources.append(
{
"name": item.stem,
"description": f"Input file: {item.name}",
"kind": "unknown",
"path": f"/data/input/{item.name}",
}
)
elif item.is_dir():
resources.append(
{
"name": item.name,
"description": f"Input directory: {item.name}",
"kind": "unknown",
"path": f"/data/input/{item.name}",
}
)
temp_path = Path(mkdtemp(prefix="fuzzforge-input-"))
input_data = {
"settings": configuration or {},
"resources": resources,
}
input_json_path.write_text(json.dumps(input_data, indent=2))
# Copy assets to temp directory
logger.debug("using source directory directly", path=str(assets_path))
return assets_path
# File input: extract to a directory first
# Determine input directory location
if project_path:
# Store inputs in .fuzzforge/inputs/ for visibility
from fuzzforge_runner.storage import FUZZFORGE_DIR_NAME
exec_id = execution_id or "latest"
input_dir = project_path / FUZZFORGE_DIR_NAME / "inputs" / exec_id
input_dir.mkdir(parents=True, exist_ok=True)
# Clean previous contents if exists
import shutil
for item in input_dir.iterdir():
if item.is_file():
item.unlink()
elif item.is_dir():
shutil.rmtree(item)
else:
# Fallback to temporary directory
from tempfile import mkdtemp
input_dir = Path(mkdtemp(prefix="fuzzforge-input-"))
# Copy/extract assets to input directory
if assets_path.exists():
if assets_path.is_file():
# Check if it's a tar.gz archive that needs extraction
@@ -353,26 +413,26 @@ class ModuleExecutor:
import tarfile
with tarfile.open(assets_path, "r:gz") as tar:
tar.extractall(path=temp_path)
tar.extractall(path=input_dir)
logger.debug("extracted tar.gz archive", archive=str(assets_path))
else:
# Single file - copy it
import shutil
shutil.copy2(assets_path, temp_path / assets_path.name)
shutil.copy2(assets_path, input_dir / assets_path.name)
else:
# Directory - copy all files (including subdirectories)
import shutil
for item in assets_path.iterdir():
if item.is_file():
shutil.copy2(item, temp_path / item.name)
shutil.copy2(item, input_dir / item.name)
elif item.is_dir():
shutil.copytree(item, temp_path / item.name)
shutil.copytree(item, input_dir / item.name, dirs_exist_ok=True)
# Scan files and directories and build resource list
resources = []
for item in temp_path.iterdir():
for item in input_dir.iterdir():
if item.name == "input.json":
continue
if item.is_file():
@@ -399,11 +459,11 @@ class ModuleExecutor:
"settings": configuration or {},
"resources": resources,
}
input_json_path = temp_path / "input.json"
input_json_path = input_dir / "input.json"
input_json_path.write_text(json.dumps(input_data, indent=2))
logger.debug("prepared input directory", resources=len(resources), path=str(temp_path))
return temp_path
logger.debug("prepared input directory", resources=len(resources), path=str(input_dir))
return input_dir
except Exception as exc:
message = f"Failed to prepare input directory"
@@ -542,6 +602,8 @@ class ModuleExecutor:
module_identifier: str,
assets_path: Path,
configuration: dict[str, Any] | None = None,
project_path: Path | None = None,
execution_id: str | None = None,
) -> Path:
"""Execute a module end-to-end.
@@ -552,9 +614,17 @@ class ModuleExecutor:
4. Pull results
5. Terminate sandbox
All intermediate files are stored in {project_path}/.fuzzforge/ for
easy debugging and visibility.
Source directories are mounted directly without tar.gz compression
for better performance.
:param module_identifier: Name/identifier of the module to execute.
:param assets_path: Path to the input assets archive.
:param assets_path: Path to the input assets (file or directory).
:param configuration: Optional module configuration.
:param project_path: Project directory for .fuzzforge/ storage.
:param execution_id: Execution ID for organizing files.
:returns: Path to the results archive.
:raises ModuleExecutionError: If any step fails.
@@ -562,10 +632,20 @@ class ModuleExecutor:
logger = get_logger()
sandbox: str | None = None
input_dir: Path | None = None
# Don't cleanup if we're using the source directory directly
cleanup_input = False
try:
# 1. Prepare input directory with assets
input_dir = self.prepare_input_directory(assets_path, configuration)
input_dir = self.prepare_input_directory(
assets_path,
configuration,
project_path=project_path,
execution_id=execution_id,
)
# Only cleanup if we created a temp directory (file input case)
cleanup_input = input_dir != assets_path and project_path is None
# 2. Spawn sandbox with volume mount
sandbox = self.spawn_sandbox(module_identifier, input_volume=input_dir)
@@ -585,12 +665,12 @@ class ModuleExecutor:
return results_path
finally:
# 5. Always cleanup
# 5. Always cleanup sandbox
if sandbox:
self.terminate_sandbox(sandbox)
if input_dir and input_dir.exists():
# Only cleanup input if it was a temp directory
if cleanup_input and input_dir and input_dir.exists():
import shutil
shutil.rmtree(input_dir, ignore_errors=True)
# -------------------------------------------------------------------------
@@ -602,22 +682,34 @@ class ModuleExecutor:
module_identifier: str,
assets_path: Path,
configuration: dict[str, Any] | None = None,
project_path: Path | None = None,
execution_id: str | None = None,
) -> dict[str, Any]:
"""Start a module in continuous/background mode without waiting.
Returns immediately with container info. Use read_module_output() to
get current status and stop_module_continuous() to stop.
Source directories are mounted directly without tar.gz compression
for better performance.
:param module_identifier: Name/identifier of the module to execute.
:param assets_path: Path to the input assets archive.
:param assets_path: Path to the input assets (file or directory).
:param configuration: Optional module configuration.
:param project_path: Project directory for .fuzzforge/ storage.
:param execution_id: Execution ID for organizing files.
:returns: Dict with container_id, input_dir for later cleanup.
"""
logger = get_logger()
# 1. Prepare input directory with assets
input_dir = self.prepare_input_directory(assets_path, configuration)
input_dir = self.prepare_input_directory(
assets_path,
configuration,
project_path=project_path,
execution_id=execution_id,
)
# 2. Spawn sandbox with volume mount
sandbox = self.spawn_sandbox(module_identifier, input_volume=input_dir)
@@ -214,11 +214,13 @@ class WorkflowOrchestrator:
message = f"No assets available for step {step_index}"
raise WorkflowExecutionError(message)
# Execute the module
# Execute the module (inputs stored in .fuzzforge/inputs/)
results_path = await self._executor.execute(
module_identifier=step.module_identifier,
assets_path=current_assets,
configuration=step.configuration,
project_path=project_path,
execution_id=step_execution_id,
)
completed_at = datetime.now(UTC)
@@ -53,6 +53,36 @@ class ModuleInfo:
#: Whether module image exists locally.
available: bool = True
#: Module category (analyzer, validator, fuzzer, reporter).
category: str | None = None
#: Target programming language (e.g., "rust", "python").
language: str | None = None
#: Pipeline stage name (e.g., "analysis", "fuzzing").
pipeline_stage: str | None = None
#: Numeric order in pipeline for sorting.
pipeline_order: int | None = None
#: Module identifiers that must run before this one.
dependencies: list[str] | None = None
#: Whether module supports continuous/background execution.
continuous_mode: bool = False
#: Expected runtime (e.g., "30s", "5m", "continuous").
typical_duration: str | None = None
#: Typical use cases and scenarios for this module.
use_cases: list[str] | None = None
#: Input requirements (e.g., ["rust-source-code", "Cargo.toml"]).
input_requirements: list[str] | None = None
#: Output artifacts produced (e.g., ["fuzzable_functions.json"]).
output_artifacts: list[str] | None = None
class Runner:
"""Main FuzzForge Runner interface.
@@ -125,16 +155,19 @@ class Runner:
return self._storage.init_project(project_path)
def set_project_assets(self, project_path: Path, assets_path: Path) -> Path:
"""Set initial assets for a project.
"""Set source path for a project (no copying).
Just stores a reference to the source directory.
The source is mounted directly into containers at runtime.
:param project_path: Path to the project directory.
:param assets_path: Path to assets (file or directory).
:returns: Path to stored assets.
:param assets_path: Path to source directory.
:returns: The assets path (unchanged).
"""
logger = get_logger()
logger.info("setting project assets", project=str(project_path), assets=str(assets_path))
return self._storage.store_assets(project_path, assets_path)
return self._storage.set_project_assets(project_path, assets_path)
# -------------------------------------------------------------------------
# Module Discovery
@@ -182,12 +215,15 @@ class Runner:
"""List available module images from the container engine.
Uses the container engine API to discover built module images.
Reads metadata from pyproject.toml inside each image.
:param filter_prefix: Prefix to filter images (default: "fuzzforge-").
:param include_all_tags: If True, include all image tags, not just 'latest'.
:returns: List of available module images.
"""
import tomllib # noqa: PLC0415
logger = get_logger()
modules: list[ModuleInfo] = []
seen: set[str] = set()
@@ -223,18 +259,67 @@ class Runner:
# Add unique modules
if module_name not in seen:
seen.add(module_name)
# Read metadata from pyproject.toml inside the image
image_ref = f"{image.repository}:{image.tag}"
module_meta = self._get_module_metadata_from_image(engine, image_ref)
# Get basic info from pyproject.toml [project] section
project_info = module_meta.get("_project", {})
fuzzforge_meta = module_meta.get("module", {})
modules.append(
ModuleInfo(
identifier=module_name,
description=None,
version=image.tag,
identifier=fuzzforge_meta.get("identifier", module_name),
description=project_info.get("description"),
version=project_info.get("version", image.tag),
available=True,
category=fuzzforge_meta.get("category"),
language=fuzzforge_meta.get("language"),
pipeline_stage=fuzzforge_meta.get("pipeline_stage"),
pipeline_order=fuzzforge_meta.get("pipeline_order"),
dependencies=fuzzforge_meta.get("dependencies", []),
continuous_mode=fuzzforge_meta.get("continuous_mode", False),
typical_duration=fuzzforge_meta.get("typical_duration"),
use_cases=fuzzforge_meta.get("use_cases", []),
input_requirements=fuzzforge_meta.get("input_requirements", []),
output_artifacts=fuzzforge_meta.get("output_artifacts", []),
)
)
logger.info("listed module images", count=len(modules))
return modules
def _get_module_metadata_from_image(self, engine: Any, image_ref: str) -> dict:
"""Read module metadata from pyproject.toml inside a container image.
:param engine: Container engine instance.
:param image_ref: Image reference (e.g., "fuzzforge-rust-analyzer:latest").
:returns: Dict with module metadata from [tool.fuzzforge] section.
"""
import tomllib # noqa: PLC0415
logger = get_logger()
try:
# Read pyproject.toml from the image
content = engine.read_file_from_image(image_ref, "/app/pyproject.toml")
if not content:
logger.debug("no pyproject.toml found in image", image=image_ref)
return {}
pyproject = tomllib.loads(content)
# Return the [tool.fuzzforge] section plus [project] info
result = pyproject.get("tool", {}).get("fuzzforge", {})
result["_project"] = pyproject.get("project", {})
return result
except Exception as exc:
logger.debug("failed to read metadata from image", image=image_ref, error=str(exc))
return {}
def get_module_info(self, module_identifier: str) -> ModuleInfo | None:
"""Get information about a specific module.
@@ -34,23 +34,14 @@ class EngineSettings(BaseModel):
class StorageSettings(BaseModel):
"""Storage configuration for local or S3 storage."""
"""Storage configuration for local filesystem storage.
#: Storage backend type.
type: Literal["local", "s3"] = "local"
OSS uses direct file mounting without archiving for simplicity.
"""
#: Base path for local storage (used when type is "local").
#: Base path for local storage.
path: Path = Field(default=Path.home() / ".fuzzforge" / "storage")
#: S3 endpoint URL (used when type is "s3").
s3_endpoint: str | None = None
#: S3 access key (used when type is "s3").
s3_access_key: str | None = None
#: S3 secret key (used when type is "s3").
s3_secret_key: str | None = None
class ProjectSettings(BaseModel):
"""Project configuration."""
@@ -1,16 +1,20 @@
"""FuzzForge Runner - Local filesystem storage.
This module provides local filesystem storage as an alternative to S3,
enabling zero-configuration operation for OSS deployments.
This module provides local filesystem storage for OSS deployments.
Storage is placed directly in the project directory as `.fuzzforge/`
for maximum visibility and ease of debugging.
In OSS mode, source files are referenced (not copied) and mounted
directly into containers at runtime for zero-copy performance.
"""
from __future__ import annotations
import shutil
from pathlib import Path, PurePath
from pathlib import Path
from tarfile import open as Archive # noqa: N812
from tempfile import NamedTemporaryFile, TemporaryDirectory
from typing import TYPE_CHECKING, cast
from fuzzforge_runner.constants import RESULTS_ARCHIVE_FILENAME
@@ -19,6 +23,9 @@ from fuzzforge_runner.exceptions import StorageError
if TYPE_CHECKING:
from structlog.stdlib import BoundLogger
#: Name of the FuzzForge storage directory within projects.
FUZZFORGE_DIR_NAME: str = ".fuzzforge"
def get_logger() -> BoundLogger:
"""Get structlog logger instance.
@@ -32,33 +39,36 @@ def get_logger() -> BoundLogger:
class LocalStorage:
"""Local filesystem storage backend.
"""Local filesystem storage backend for FuzzForge OSS.
Provides S3-like operations using local filesystem, enabling
FuzzForge operation without external storage infrastructure.
Provides lightweight storage for execution results while using
direct source mounting (no copying) for input assets.
Directory structure:
{base_path}/
projects/
{project_id}/
assets/ # Initial project assets
runs/
{execution_id}/
Storage is placed directly in the project directory as `.fuzzforge/`
so users can easily inspect outputs and configuration.
Directory structure (inside project directory):
{project_path}/.fuzzforge/
config.json # Project config (source path reference)
runs/ # Execution results
{execution_id}/
results.tar.gz
{workflow_id}/
modules/
step-0-{exec_id}/
results.tar.gz
{workflow_id}/
modules/
step-0-{exec_id}/
results.tar.gz
Source files are NOT copied - they are referenced and mounted directly.
"""
#: Base path for all storage operations.
#: Base path for global storage (only used for fallback/config).
_base_path: Path
def __init__(self, base_path: Path) -> None:
"""Initialize an instance of the class.
:param base_path: Root directory for storage.
:param base_path: Root directory for global storage (fallback only).
"""
self._base_path = base_path
@@ -71,17 +81,22 @@ class LocalStorage:
def _get_project_path(self, project_path: Path) -> Path:
"""Get the storage path for a project.
:param project_path: Original project path (used as identifier).
:returns: Storage path for the project.
Storage is placed directly inside the project as `.fuzzforge/`.
:param project_path: Path to the project directory.
:returns: Storage path for the project (.fuzzforge inside project).
"""
# Use project path name as identifier
project_id = project_path.name
return self._base_path / "projects" / project_id
return project_path / FUZZFORGE_DIR_NAME
def init_project(self, project_path: Path) -> Path:
"""Initialize storage for a new project.
Creates a .fuzzforge/ directory inside the project for storing:
- assets/: Input files (source code, etc.)
- inputs/: Prepared module inputs (for debugging)
- runs/: Execution results from each module
:param project_path: Path to the project directory.
:returns: Path to the project storage directory.
@@ -89,102 +104,91 @@ class LocalStorage:
logger = get_logger()
storage_path = self._get_project_path(project_path)
# Create directory structure
(storage_path / "assets").mkdir(parents=True, exist_ok=True)
# Create directory structure (minimal for OSS)
storage_path.mkdir(parents=True, exist_ok=True)
(storage_path / "runs").mkdir(parents=True, exist_ok=True)
# Create .gitignore to avoid committing large files
gitignore_path = storage_path / ".gitignore"
if not gitignore_path.exists():
gitignore_content = """# FuzzForge storage - ignore large/temporary files
# Execution results (can be very large)
runs/
# Project configuration
!config.json
"""
gitignore_path.write_text(gitignore_content)
logger.info("initialized project storage", project=project_path.name, storage=str(storage_path))
return storage_path
def get_project_assets_path(self, project_path: Path) -> Path | None:
"""Get the path to project assets archive.
"""Get the path to project assets (source directory).
Returns the configured source path for the project.
In OSS mode, this is just a reference to the user's source - no copying.
:param project_path: Path to the project directory.
:returns: Path to assets archive, or None if not found.
:returns: Path to source directory, or None if not configured.
"""
storage_path = self._get_project_path(project_path)
assets_dir = storage_path / "assets"
config_path = storage_path / "config.json"
# Look for assets archive
archive_path = assets_dir / "assets.tar.gz"
if archive_path.exists():
return archive_path
if config_path.exists():
import json
config = json.loads(config_path.read_text())
source_path = config.get("source_path")
if source_path:
path = Path(source_path)
if path.exists():
return path
# Check if there are any files in assets directory
if assets_dir.exists() and any(assets_dir.iterdir()):
# Create archive from directory contents
return self._create_archive_from_directory(assets_dir)
# Fallback: check if project_path itself is the source
# (common case: user runs from their project directory)
if (project_path / "Cargo.toml").exists() or (project_path / "src").exists():
return project_path
return None
def _create_archive_from_directory(self, directory: Path) -> Path:
"""Create a tar.gz archive from a directory's contents.
def set_project_assets(self, project_path: Path, assets_path: Path) -> Path:
"""Set the source path for a project (no copying).
:param directory: Directory to archive.
:returns: Path to the created archive.
"""
archive_path = directory.parent / f"{directory.name}.tar.gz"
with Archive(archive_path, "w:gz") as tar:
for item in directory.iterdir():
tar.add(item, arcname=item.name)
return archive_path
def create_empty_assets_archive(self, project_path: Path) -> Path:
"""Create an empty assets archive for a project.
Just stores a reference to the source directory.
The source is mounted directly into containers at runtime.
:param project_path: Path to the project directory.
:returns: Path to the empty archive.
:param assets_path: Path to source directory.
:returns: The assets path (unchanged).
:raises StorageError: If path doesn't exist.
"""
storage_path = self._get_project_path(project_path)
assets_dir = storage_path / "assets"
assets_dir.mkdir(parents=True, exist_ok=True)
import json
archive_path = assets_dir / "assets.tar.gz"
# Create empty archive
with Archive(archive_path, "w:gz") as tar:
pass # Empty archive
return archive_path
def store_assets(self, project_path: Path, assets_path: Path) -> Path:
"""Store project assets from a local path.
:param project_path: Path to the project directory.
:param assets_path: Source path (file or directory) to store.
:returns: Path to the stored assets.
:raises StorageError: If storage operation fails.
"""
logger = get_logger()
if not assets_path.exists():
raise StorageError(f"Assets path does not exist: {assets_path}")
# Resolve to absolute path
assets_path = assets_path.resolve()
# Store reference in config
storage_path = self._get_project_path(project_path)
assets_dir = storage_path / "assets"
assets_dir.mkdir(parents=True, exist_ok=True)
storage_path.mkdir(parents=True, exist_ok=True)
config_path = storage_path / "config.json"
try:
if assets_path.is_file():
# Copy archive directly
dest_path = assets_dir / "assets.tar.gz"
shutil.copy2(assets_path, dest_path)
else:
# Create archive from directory
dest_path = assets_dir / "assets.tar.gz"
with Archive(dest_path, "w:gz") as tar:
for item in assets_path.iterdir():
tar.add(item, arcname=item.name)
config: dict = {}
if config_path.exists():
config = json.loads(config_path.read_text())
logger.info("stored project assets", project=project_path.name, path=str(dest_path))
return dest_path
config["source_path"] = str(assets_path)
config_path.write_text(json.dumps(config, indent=2))
except Exception as exc:
message = f"Failed to store assets: {exc}"
raise StorageError(message) from exc
logger.info("set project assets", project=project_path.name, source=str(assets_path))
return assets_path
def store_execution_results(
self,