diff --git a/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/docker/cli.py b/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/docker/cli.py index d7eeada..9560bf0 100644 --- a/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/docker/cli.py +++ b/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/docker/cli.py @@ -95,8 +95,8 @@ class DockerCLI(AbstractFuzzForgeSandboxEngine): continue reference = f"{repo}:{tag}" - - if filter_prefix and not reference.startswith(filter_prefix): + + if filter_prefix and filter_prefix not in reference: continue images.append( diff --git a/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/podman/cli.py b/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/podman/cli.py index dfd29f3..c6d2dd1 100644 --- a/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/podman/cli.py +++ b/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/podman/cli.py @@ -31,14 +31,15 @@ def get_logger() -> BoundLogger: def _is_running_under_snap() -> bool: """Check if running under Snap environment. - + VS Code installed via Snap sets XDG_DATA_HOME to a version-specific path, causing Podman to look for storage in non-standard locations. When SNAP is set, we use custom storage paths to ensure consistency. - + Note: Snap only exists on Linux, so this also handles macOS implicitly. """ import os # noqa: PLC0415 + return os.getenv("SNAP") is not None @@ -48,11 +49,11 @@ class PodmanCLI(AbstractFuzzForgeSandboxEngine): This implementation uses subprocess calls to the Podman CLI with --root and --runroot flags when running under Snap, providing isolation from system Podman storage. - + The custom storage is only used when: 1. Running under Snap (SNAP env var is set) - to fix XDG_DATA_HOME issues 2. Custom paths are explicitly provided - + Otherwise, uses default Podman storage which works for: - Native Linux installations - macOS (where Podman runs in a VM via podman machine) @@ -69,16 +70,24 @@ class PodmanCLI(AbstractFuzzForgeSandboxEngine): :param runroot: Path to container runtime state. Custom storage is used when running under Snap AND paths are provided. + + :raises FuzzForgeError: If running on macOS (Podman not supported). """ + import sys # noqa: PLC0415 + + if sys.platform == "darwin": + msg = ( + "Podman is not supported on macOS. Please use Docker instead:\n" + " brew install --cask docker\n" + " # Or download from https://docker.com/products/docker-desktop" + ) + raise FuzzForgeError(msg) + AbstractFuzzForgeSandboxEngine.__init__(self) - + # Use custom storage only under Snap (to fix XDG_DATA_HOME issues) - self.__use_custom_storage = ( - _is_running_under_snap() - and graphroot is not None - and runroot is not None - ) - + self.__use_custom_storage = _is_running_under_snap() and graphroot is not None and runroot is not None + if self.__use_custom_storage: self.__graphroot = graphroot self.__runroot = runroot @@ -98,8 +107,10 @@ class PodmanCLI(AbstractFuzzForgeSandboxEngine): if self.__use_custom_storage and self.__graphroot and self.__runroot: return [ "podman", - "--root", str(self.__graphroot), - "--runroot", str(self.__runroot), + "--root", + str(self.__graphroot), + "--runroot", + str(self.__runroot), ] return ["podman"] diff --git a/fuzzforge-common/tests/unit/engines/test_podman.py b/fuzzforge-common/tests/unit/engines/test_podman.py index 5dd5db3..4bfd88a 100644 --- a/fuzzforge-common/tests/unit/engines/test_podman.py +++ b/fuzzforge-common/tests/unit/engines/test_podman.py @@ -2,32 +2,41 @@ import os import shutil +import sys import uuid from pathlib import Path from unittest import mock import pytest +from fuzzforge_common.exceptions import FuzzForgeError from fuzzforge_common.sandboxes.engines.podman.cli import PodmanCLI, _is_running_under_snap +# Helper to mock Linux platform for testing (since Podman is Linux-only) +def _mock_linux_platform() -> mock._patch[str]: + """Context manager to mock sys.platform as 'linux'.""" + return mock.patch.object(sys, "platform", "linux") + + @pytest.fixture def podman_cli_engine() -> PodmanCLI: """Create a PodmanCLI engine with temporary storage. - + Uses short paths in /tmp to avoid podman's 50-char runroot limit. Simulates Snap environment to test custom storage paths. + Mocks Linux platform since Podman is Linux-only. """ short_id = str(uuid.uuid4())[:8] graphroot = Path(f"/tmp/ff-{short_id}/storage") runroot = Path(f"/tmp/ff-{short_id}/run") - - # Simulate Snap environment for testing - with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): + + # Simulate Snap environment for testing on Linux + with _mock_linux_platform(), mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): engine = PodmanCLI(graphroot=graphroot, runroot=runroot) - + yield engine - + # Cleanup parent = graphroot.parent if parent.exists(): @@ -48,21 +57,30 @@ def test_snap_detection_when_snap_not_set() -> None: assert _is_running_under_snap() is False +def test_podman_cli_blocks_macos() -> None: + """Test that PodmanCLI raises error on macOS.""" + with mock.patch.object(sys, "platform", "darwin"): + with pytest.raises(FuzzForgeError) as exc_info: + PodmanCLI() + assert "Podman is not supported on macOS" in str(exc_info.value) + assert "Docker" in str(exc_info.value) + + def test_podman_cli_creates_storage_directories_under_snap() -> None: """Test that PodmanCLI creates storage directories when under Snap.""" short_id = str(uuid.uuid4())[:8] graphroot = Path(f"/tmp/ff-{short_id}/storage") runroot = Path(f"/tmp/ff-{short_id}/run") - + assert not graphroot.exists() assert not runroot.exists() - - with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): - engine = PodmanCLI(graphroot=graphroot, runroot=runroot) - + + with _mock_linux_platform(), mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): + PodmanCLI(graphroot=graphroot, runroot=runroot) + assert graphroot.exists() assert runroot.exists() - + # Cleanup shutil.rmtree(graphroot.parent, ignore_errors=True) @@ -72,15 +90,15 @@ def test_podman_cli_base_cmd_under_snap() -> None: short_id = str(uuid.uuid4())[:8] graphroot = Path(f"/tmp/ff-{short_id}/storage") runroot = Path(f"/tmp/ff-{short_id}/run") - - with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): + + with _mock_linux_platform(), mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): engine = PodmanCLI(graphroot=graphroot, runroot=runroot) base_cmd = engine._base_cmd() - + assert "podman" in base_cmd assert "--root" in base_cmd assert "--runroot" in base_cmd - + # Cleanup shutil.rmtree(graphroot.parent, ignore_errors=True) @@ -90,25 +108,26 @@ def test_podman_cli_base_cmd_without_snap() -> None: short_id = str(uuid.uuid4())[:8] graphroot = Path(f"/tmp/ff-{short_id}/storage") runroot = Path(f"/tmp/ff-{short_id}/run") - + env = os.environ.copy() env.pop("SNAP", None) - with mock.patch.dict(os.environ, env, clear=True): + with _mock_linux_platform(), mock.patch.dict(os.environ, env, clear=True): engine = PodmanCLI(graphroot=graphroot, runroot=runroot) base_cmd = engine._base_cmd() - + assert base_cmd == ["podman"] assert "--root" not in base_cmd - + # Directories should NOT be created when not under Snap assert not graphroot.exists() def test_podman_cli_default_mode() -> None: """Test PodmanCLI without custom storage paths.""" - engine = PodmanCLI() # No paths provided - base_cmd = engine._base_cmd() - + with _mock_linux_platform(): + engine = PodmanCLI() # No paths provided + base_cmd = engine._base_cmd() + assert base_cmd == ["podman"] assert "--root" not in base_cmd @@ -116,7 +135,7 @@ def test_podman_cli_default_mode() -> None: def test_podman_cli_list_images_returns_list(podman_cli_engine: PodmanCLI) -> None: """Test that list_images returns a list (even if empty).""" images = podman_cli_engine.list_images() - + assert isinstance(images, list) @@ -125,6 +144,6 @@ def test_podman_cli_can_pull_and_list_image(podman_cli_engine: PodmanCLI) -> Non """Test pulling an image and listing it.""" # Pull a small image podman_cli_engine._run(["pull", "docker.io/library/alpine:latest"]) - + images = podman_cli_engine.list_images() assert any("alpine" in img.identifier for img in images) diff --git a/fuzzforge-runner/src/fuzzforge_runner/executor.py b/fuzzforge-runner/src/fuzzforge_runner/executor.py index 0308da8..57ed320 100644 --- a/fuzzforge-runner/src/fuzzforge_runner/executor.py +++ b/fuzzforge-runner/src/fuzzforge_runner/executor.py @@ -136,7 +136,7 @@ class ModuleExecutor: # - fuzzforge-module-{name}:{tag} (OSS local builds with module prefix) # - localhost/fuzzforge-module-{name}:{tag} (standard convention) # - localhost/{name}:{tag} (legacy/short form) - + # For OSS local builds (no localhost/ prefix) for tag in tags_to_check: # Check direct module name (fuzzforge-cargo-fuzzer:0.1.0) @@ -146,7 +146,7 @@ class ModuleExecutor: if not module_identifier.startswith("fuzzforge-"): if engine.image_exists(f"fuzzforge-{module_identifier}:{tag}"): return True - + # For registry-style naming (localhost/ prefix) name_prefixes = [f"fuzzforge-module-{module_identifier}", module_identifier] @@ -166,17 +166,17 @@ class ModuleExecutor: """ engine = self._get_engine() - + # Try common tags tags_to_check = ["latest", "0.1.0", "0.0.1"] - + # Check OSS local builds first (no localhost/ prefix) for tag in tags_to_check: # Direct module name (fuzzforge-cargo-fuzzer:0.1.0) direct_name = f"{module_identifier}:{tag}" if engine.image_exists(direct_name): return direct_name - + # With fuzzforge- prefix if not already present if not module_identifier.startswith("fuzzforge-"): prefixed_name = f"fuzzforge-{module_identifier}:{tag}" @@ -189,7 +189,7 @@ class ModuleExecutor: prefixed_name = f"localhost/fuzzforge-module-{module_identifier}:{tag}" if engine.image_exists(prefixed_name): return prefixed_name - + # Legacy short form: localhost/{name}:{tag} short_name = f"localhost/{module_identifier}:{tag}" if engine.image_exists(short_name): @@ -198,7 +198,7 @@ class ModuleExecutor: # Default fallback return f"localhost/{module_identifier}:latest" - def _pull_module_image(self, module_identifier: str, registry_url: str = "ghcr.io/fuzzinglabs", tag: str = "latest") -> None: + def _pull_module_image(self, module_identifier: str, registry_url: str, tag: str = "latest") -> None: """Pull a module image from the container registry. :param module_identifier: Name/identifier of the module to pull. @@ -238,21 +238,30 @@ class ModuleExecutor: ) raise SandboxError(message) from exc - def _ensure_module_image(self, module_identifier: str, registry_url: str = "ghcr.io/fuzzinglabs", tag: str = "latest") -> None: + def _ensure_module_image(self, module_identifier: str, registry_url: str = "", tag: str = "latest") -> None: """Ensure module image exists, pulling it if necessary. :param module_identifier: Name/identifier of the module image. - :param registry_url: Container registry URL to pull from. + :param registry_url: Container registry URL to pull from (empty = local-only mode). :param tag: Image tag to pull. - :raises SandboxError: If image check or pull fails. + :raises SandboxError: If image not found locally and no registry configured. """ logger = get_logger() - + if self._check_image_exists(module_identifier): logger.debug("module image exists locally", module=module_identifier) return - + + # If no registry configured, we're in local-only mode + if not registry_url: + raise SandboxError( + f"Module image '{module_identifier}' not found locally.\n" + "Build it with: make build-modules\n" + "\n" + "Or configure a registry URL via FUZZFORGE_REGISTRY__URL environment variable." + ) + logger.info( "module image not found locally, pulling from registry", module=module_identifier, @@ -260,7 +269,7 @@ class ModuleExecutor: info="This may take a moment on first run", ) self._pull_module_image(module_identifier, registry_url, tag) - + # Verify image now exists if not self._check_image_exists(module_identifier): message = ( @@ -332,6 +341,7 @@ class ModuleExecutor: try: # Create temporary directory - caller must clean it up after container finishes from tempfile import mkdtemp + temp_path = Path(mkdtemp(prefix="fuzzforge-input-")) # Copy assets to temp directory @@ -341,16 +351,19 @@ class ModuleExecutor: if assets_path.suffix == ".gz" or assets_path.name.endswith(".tar.gz"): # Extract archive contents import tarfile + with tarfile.open(assets_path, "r:gz") as tar: tar.extractall(path=temp_path) 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) 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) @@ -363,19 +376,23 @@ class ModuleExecutor: 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}", - }) + 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}", - }) + resources.append( + { + "name": item.name, + "description": f"Input directory: {item.name}", + "kind": "unknown", + "path": f"/data/input/{item.name}", + } + ) # Create input.json with settings and resources input_data = { @@ -461,6 +478,7 @@ class ModuleExecutor: try: # Create temporary directory for results from tempfile import mkdtemp + temp_dir = Path(mkdtemp(prefix="fuzzforge-results-")) # Copy entire output directory from container @@ -489,6 +507,7 @@ class ModuleExecutor: # Clean up temp directory import shutil + shutil.rmtree(temp_dir, ignore_errors=True) logger.info("results pulled successfully", sandbox=sandbox, archive=str(archive_path)) @@ -571,6 +590,7 @@ class ModuleExecutor: self.terminate_sandbox(sandbox) if input_dir and input_dir.exists(): import shutil + shutil.rmtree(input_dir, ignore_errors=True) # ------------------------------------------------------------------------- @@ -669,4 +689,5 @@ class ModuleExecutor: self.terminate_sandbox(container_id) if input_dir: import shutil + shutil.rmtree(input_dir, ignore_errors=True) diff --git a/fuzzforge-runner/src/fuzzforge_runner/settings.py b/fuzzforge-runner/src/fuzzforge_runner/settings.py index 9074205..aa98ab8 100644 --- a/fuzzforge-runner/src/fuzzforge_runner/settings.py +++ b/fuzzforge-runner/src/fuzzforge_runner/settings.py @@ -60,10 +60,15 @@ class ProjectSettings(BaseModel): class RegistrySettings(BaseModel): - """Container registry configuration for module images.""" + """Container registry configuration for module images. - #: Registry URL for pulling module images. - url: str = Field(default="ghcr.io/fuzzinglabs") + By default, registry URL is empty (local-only mode). When empty, + modules must be built locally with `make build-modules`. + Set via FUZZFORGE_REGISTRY__URL environment variable if needed. + """ + + #: Registry URL for pulling module images (empty = local-only mode). + url: str = Field(default="") #: Default tag to use when pulling images. default_tag: str = Field(default="latest") diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..f8c919b --- /dev/null +++ b/ruff.toml @@ -0,0 +1,20 @@ +line-length = 120 + +[lint] +select = [ "ALL" ] +ignore = [ + "COM812", # conflicts with the formatter + "D100", # ignoring missing docstrings in public modules + "D104", # ignoring missing docstrings in public packages + "D203", # conflicts with 'D211' + "D213", # conflicts with 'D212' + "TD002", # ignoring missing author in 'TODO' statements + "TD003", # ignoring missing issue link in 'TODO' statements +] + +[lint.per-file-ignores] +"tests/*" = [ + "ANN401", # allowing 'typing.Any' to be used to type function parameters in tests + "PLR2004", # allowing comparisons using unamed numerical constants in tests + "S101", # allowing 'assert' statements in tests +]