fix: block Podman on macOS and remove ghcr.io default

- Add platform check in PodmanCLI.__init__() that raises FuzzForgeError
  on macOS with instructions to use Docker instead
- Change RegistrySettings.url default from "ghcr.io/fuzzinglabs" to ""
  (empty string) for local-only mode since no images are published yet
- Update _ensure_module_image() to show helpful error when image not
  found locally and no registry configured
- Update tests to mock Linux platform for Podman tests
- Add root ruff.toml to fix broken configuration in fuzzforge-runner
This commit is contained in:
tduhamel42
2026-01-31 10:27:03 +01:00
parent e72c5fb201
commit ac6e8a8a34
6 changed files with 144 additions and 68 deletions
@@ -95,8 +95,8 @@ class DockerCLI(AbstractFuzzForgeSandboxEngine):
continue continue
reference = f"{repo}:{tag}" reference = f"{repo}:{tag}"
if filter_prefix and not reference.startswith(filter_prefix): if filter_prefix and filter_prefix not in reference:
continue continue
images.append( images.append(
@@ -31,14 +31,15 @@ def get_logger() -> BoundLogger:
def _is_running_under_snap() -> bool: def _is_running_under_snap() -> bool:
"""Check if running under Snap environment. """Check if running under Snap environment.
VS Code installed via Snap sets XDG_DATA_HOME to a version-specific path, 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 causing Podman to look for storage in non-standard locations. When SNAP
is set, we use custom storage paths to ensure consistency. is set, we use custom storage paths to ensure consistency.
Note: Snap only exists on Linux, so this also handles macOS implicitly. Note: Snap only exists on Linux, so this also handles macOS implicitly.
""" """
import os # noqa: PLC0415 import os # noqa: PLC0415
return os.getenv("SNAP") is not None 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 This implementation uses subprocess calls to the Podman CLI with --root
and --runroot flags when running under Snap, providing isolation from and --runroot flags when running under Snap, providing isolation from
system Podman storage. system Podman storage.
The custom storage is only used when: The custom storage is only used when:
1. Running under Snap (SNAP env var is set) - to fix XDG_DATA_HOME issues 1. Running under Snap (SNAP env var is set) - to fix XDG_DATA_HOME issues
2. Custom paths are explicitly provided 2. Custom paths are explicitly provided
Otherwise, uses default Podman storage which works for: Otherwise, uses default Podman storage which works for:
- Native Linux installations - Native Linux installations
- macOS (where Podman runs in a VM via podman machine) - macOS (where Podman runs in a VM via podman machine)
@@ -69,16 +70,24 @@ class PodmanCLI(AbstractFuzzForgeSandboxEngine):
:param runroot: Path to container runtime state. :param runroot: Path to container runtime state.
Custom storage is used when running under Snap AND paths are provided. 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) AbstractFuzzForgeSandboxEngine.__init__(self)
# Use custom storage only under Snap (to fix XDG_DATA_HOME issues) # Use custom storage only under Snap (to fix XDG_DATA_HOME issues)
self.__use_custom_storage = ( self.__use_custom_storage = _is_running_under_snap() and graphroot is not None and runroot is not None
_is_running_under_snap()
and graphroot is not None
and runroot is not None
)
if self.__use_custom_storage: if self.__use_custom_storage:
self.__graphroot = graphroot self.__graphroot = graphroot
self.__runroot = runroot self.__runroot = runroot
@@ -98,8 +107,10 @@ class PodmanCLI(AbstractFuzzForgeSandboxEngine):
if self.__use_custom_storage and self.__graphroot and self.__runroot: if self.__use_custom_storage and self.__graphroot and self.__runroot:
return [ return [
"podman", "podman",
"--root", str(self.__graphroot), "--root",
"--runroot", str(self.__runroot), str(self.__graphroot),
"--runroot",
str(self.__runroot),
] ]
return ["podman"] return ["podman"]
@@ -2,32 +2,41 @@
import os import os
import shutil import shutil
import sys
import uuid import uuid
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
import pytest import pytest
from fuzzforge_common.exceptions import FuzzForgeError
from fuzzforge_common.sandboxes.engines.podman.cli import PodmanCLI, _is_running_under_snap 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 @pytest.fixture
def podman_cli_engine() -> PodmanCLI: def podman_cli_engine() -> PodmanCLI:
"""Create a PodmanCLI engine with temporary storage. """Create a PodmanCLI engine with temporary storage.
Uses short paths in /tmp to avoid podman's 50-char runroot limit. Uses short paths in /tmp to avoid podman's 50-char runroot limit.
Simulates Snap environment to test custom storage paths. Simulates Snap environment to test custom storage paths.
Mocks Linux platform since Podman is Linux-only.
""" """
short_id = str(uuid.uuid4())[:8] short_id = str(uuid.uuid4())[:8]
graphroot = Path(f"/tmp/ff-{short_id}/storage") graphroot = Path(f"/tmp/ff-{short_id}/storage")
runroot = Path(f"/tmp/ff-{short_id}/run") runroot = Path(f"/tmp/ff-{short_id}/run")
# Simulate Snap environment for testing # Simulate Snap environment for testing on Linux
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) engine = PodmanCLI(graphroot=graphroot, runroot=runroot)
yield engine yield engine
# Cleanup # Cleanup
parent = graphroot.parent parent = graphroot.parent
if parent.exists(): if parent.exists():
@@ -48,21 +57,30 @@ def test_snap_detection_when_snap_not_set() -> None:
assert _is_running_under_snap() is False 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: def test_podman_cli_creates_storage_directories_under_snap() -> None:
"""Test that PodmanCLI creates storage directories when under Snap.""" """Test that PodmanCLI creates storage directories when under Snap."""
short_id = str(uuid.uuid4())[:8] short_id = str(uuid.uuid4())[:8]
graphroot = Path(f"/tmp/ff-{short_id}/storage") graphroot = Path(f"/tmp/ff-{short_id}/storage")
runroot = Path(f"/tmp/ff-{short_id}/run") runroot = Path(f"/tmp/ff-{short_id}/run")
assert not graphroot.exists() assert not graphroot.exists()
assert not runroot.exists() assert not runroot.exists()
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) PodmanCLI(graphroot=graphroot, runroot=runroot)
assert graphroot.exists() assert graphroot.exists()
assert runroot.exists() assert runroot.exists()
# Cleanup # Cleanup
shutil.rmtree(graphroot.parent, ignore_errors=True) 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] short_id = str(uuid.uuid4())[:8]
graphroot = Path(f"/tmp/ff-{short_id}/storage") graphroot = Path(f"/tmp/ff-{short_id}/storage")
runroot = Path(f"/tmp/ff-{short_id}/run") 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) engine = PodmanCLI(graphroot=graphroot, runroot=runroot)
base_cmd = engine._base_cmd() base_cmd = engine._base_cmd()
assert "podman" in base_cmd assert "podman" in base_cmd
assert "--root" in base_cmd assert "--root" in base_cmd
assert "--runroot" in base_cmd assert "--runroot" in base_cmd
# Cleanup # Cleanup
shutil.rmtree(graphroot.parent, ignore_errors=True) 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] short_id = str(uuid.uuid4())[:8]
graphroot = Path(f"/tmp/ff-{short_id}/storage") graphroot = Path(f"/tmp/ff-{short_id}/storage")
runroot = Path(f"/tmp/ff-{short_id}/run") runroot = Path(f"/tmp/ff-{short_id}/run")
env = os.environ.copy() env = os.environ.copy()
env.pop("SNAP", None) 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) engine = PodmanCLI(graphroot=graphroot, runroot=runroot)
base_cmd = engine._base_cmd() base_cmd = engine._base_cmd()
assert base_cmd == ["podman"] assert base_cmd == ["podman"]
assert "--root" not in base_cmd assert "--root" not in base_cmd
# Directories should NOT be created when not under Snap # Directories should NOT be created when not under Snap
assert not graphroot.exists() assert not graphroot.exists()
def test_podman_cli_default_mode() -> None: def test_podman_cli_default_mode() -> None:
"""Test PodmanCLI without custom storage paths.""" """Test PodmanCLI without custom storage paths."""
engine = PodmanCLI() # No paths provided with _mock_linux_platform():
base_cmd = engine._base_cmd() engine = PodmanCLI() # No paths provided
base_cmd = engine._base_cmd()
assert base_cmd == ["podman"] assert base_cmd == ["podman"]
assert "--root" not in base_cmd 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: def test_podman_cli_list_images_returns_list(podman_cli_engine: PodmanCLI) -> None:
"""Test that list_images returns a list (even if empty).""" """Test that list_images returns a list (even if empty)."""
images = podman_cli_engine.list_images() images = podman_cli_engine.list_images()
assert isinstance(images, list) 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.""" """Test pulling an image and listing it."""
# Pull a small image # Pull a small image
podman_cli_engine._run(["pull", "docker.io/library/alpine:latest"]) podman_cli_engine._run(["pull", "docker.io/library/alpine:latest"])
images = podman_cli_engine.list_images() images = podman_cli_engine.list_images()
assert any("alpine" in img.identifier for img in images) assert any("alpine" in img.identifier for img in images)
@@ -136,7 +136,7 @@ class ModuleExecutor:
# - fuzzforge-module-{name}:{tag} (OSS local builds with module prefix) # - fuzzforge-module-{name}:{tag} (OSS local builds with module prefix)
# - localhost/fuzzforge-module-{name}:{tag} (standard convention) # - localhost/fuzzforge-module-{name}:{tag} (standard convention)
# - localhost/{name}:{tag} (legacy/short form) # - localhost/{name}:{tag} (legacy/short form)
# For OSS local builds (no localhost/ prefix) # For OSS local builds (no localhost/ prefix)
for tag in tags_to_check: for tag in tags_to_check:
# Check direct module name (fuzzforge-cargo-fuzzer:0.1.0) # Check direct module name (fuzzforge-cargo-fuzzer:0.1.0)
@@ -146,7 +146,7 @@ class ModuleExecutor:
if not module_identifier.startswith("fuzzforge-"): if not module_identifier.startswith("fuzzforge-"):
if engine.image_exists(f"fuzzforge-{module_identifier}:{tag}"): if engine.image_exists(f"fuzzforge-{module_identifier}:{tag}"):
return True return True
# For registry-style naming (localhost/ prefix) # For registry-style naming (localhost/ prefix)
name_prefixes = [f"fuzzforge-module-{module_identifier}", module_identifier] name_prefixes = [f"fuzzforge-module-{module_identifier}", module_identifier]
@@ -166,17 +166,17 @@ class ModuleExecutor:
""" """
engine = self._get_engine() engine = self._get_engine()
# Try common tags # Try common tags
tags_to_check = ["latest", "0.1.0", "0.0.1"] tags_to_check = ["latest", "0.1.0", "0.0.1"]
# Check OSS local builds first (no localhost/ prefix) # Check OSS local builds first (no localhost/ prefix)
for tag in tags_to_check: for tag in tags_to_check:
# Direct module name (fuzzforge-cargo-fuzzer:0.1.0) # Direct module name (fuzzforge-cargo-fuzzer:0.1.0)
direct_name = f"{module_identifier}:{tag}" direct_name = f"{module_identifier}:{tag}"
if engine.image_exists(direct_name): if engine.image_exists(direct_name):
return direct_name return direct_name
# With fuzzforge- prefix if not already present # With fuzzforge- prefix if not already present
if not module_identifier.startswith("fuzzforge-"): if not module_identifier.startswith("fuzzforge-"):
prefixed_name = f"fuzzforge-{module_identifier}:{tag}" prefixed_name = f"fuzzforge-{module_identifier}:{tag}"
@@ -189,7 +189,7 @@ class ModuleExecutor:
prefixed_name = f"localhost/fuzzforge-module-{module_identifier}:{tag}" prefixed_name = f"localhost/fuzzforge-module-{module_identifier}:{tag}"
if engine.image_exists(prefixed_name): if engine.image_exists(prefixed_name):
return prefixed_name return prefixed_name
# Legacy short form: localhost/{name}:{tag} # Legacy short form: localhost/{name}:{tag}
short_name = f"localhost/{module_identifier}:{tag}" short_name = f"localhost/{module_identifier}:{tag}"
if engine.image_exists(short_name): if engine.image_exists(short_name):
@@ -198,7 +198,7 @@ class ModuleExecutor:
# Default fallback # Default fallback
return f"localhost/{module_identifier}:latest" 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. """Pull a module image from the container registry.
:param module_identifier: Name/identifier of the module to pull. :param module_identifier: Name/identifier of the module to pull.
@@ -238,21 +238,30 @@ class ModuleExecutor:
) )
raise SandboxError(message) from exc 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. """Ensure module image exists, pulling it if necessary.
:param module_identifier: Name/identifier of the module image. :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. :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() logger = get_logger()
if self._check_image_exists(module_identifier): if self._check_image_exists(module_identifier):
logger.debug("module image exists locally", module=module_identifier) logger.debug("module image exists locally", module=module_identifier)
return 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( logger.info(
"module image not found locally, pulling from registry", "module image not found locally, pulling from registry",
module=module_identifier, module=module_identifier,
@@ -260,7 +269,7 @@ class ModuleExecutor:
info="This may take a moment on first run", info="This may take a moment on first run",
) )
self._pull_module_image(module_identifier, registry_url, tag) self._pull_module_image(module_identifier, registry_url, tag)
# Verify image now exists # Verify image now exists
if not self._check_image_exists(module_identifier): if not self._check_image_exists(module_identifier):
message = ( message = (
@@ -332,6 +341,7 @@ class ModuleExecutor:
try: try:
# Create temporary directory - caller must clean it up after container finishes # Create temporary directory - caller must clean it up after container finishes
from tempfile import mkdtemp from tempfile import mkdtemp
temp_path = Path(mkdtemp(prefix="fuzzforge-input-")) temp_path = Path(mkdtemp(prefix="fuzzforge-input-"))
# Copy assets to temp directory # Copy assets to temp directory
@@ -341,16 +351,19 @@ class ModuleExecutor:
if assets_path.suffix == ".gz" or assets_path.name.endswith(".tar.gz"): if assets_path.suffix == ".gz" or assets_path.name.endswith(".tar.gz"):
# Extract archive contents # Extract archive contents
import tarfile import tarfile
with tarfile.open(assets_path, "r:gz") as tar: with tarfile.open(assets_path, "r:gz") as tar:
tar.extractall(path=temp_path) tar.extractall(path=temp_path)
logger.debug("extracted tar.gz archive", archive=str(assets_path)) logger.debug("extracted tar.gz archive", archive=str(assets_path))
else: else:
# Single file - copy it # Single file - copy it
import shutil import shutil
shutil.copy2(assets_path, temp_path / assets_path.name) shutil.copy2(assets_path, temp_path / assets_path.name)
else: else:
# Directory - copy all files (including subdirectories) # Directory - copy all files (including subdirectories)
import shutil import shutil
for item in assets_path.iterdir(): for item in assets_path.iterdir():
if item.is_file(): if item.is_file():
shutil.copy2(item, temp_path / item.name) shutil.copy2(item, temp_path / item.name)
@@ -363,19 +376,23 @@ class ModuleExecutor:
if item.name == "input.json": if item.name == "input.json":
continue continue
if item.is_file(): if item.is_file():
resources.append({ resources.append(
"name": item.stem, {
"description": f"Input file: {item.name}", "name": item.stem,
"kind": "unknown", "description": f"Input file: {item.name}",
"path": f"/data/input/{item.name}", "kind": "unknown",
}) "path": f"/data/input/{item.name}",
}
)
elif item.is_dir(): elif item.is_dir():
resources.append({ resources.append(
"name": item.name, {
"description": f"Input directory: {item.name}", "name": item.name,
"kind": "unknown", "description": f"Input directory: {item.name}",
"path": f"/data/input/{item.name}", "kind": "unknown",
}) "path": f"/data/input/{item.name}",
}
)
# Create input.json with settings and resources # Create input.json with settings and resources
input_data = { input_data = {
@@ -461,6 +478,7 @@ class ModuleExecutor:
try: try:
# Create temporary directory for results # Create temporary directory for results
from tempfile import mkdtemp from tempfile import mkdtemp
temp_dir = Path(mkdtemp(prefix="fuzzforge-results-")) temp_dir = Path(mkdtemp(prefix="fuzzforge-results-"))
# Copy entire output directory from container # Copy entire output directory from container
@@ -489,6 +507,7 @@ class ModuleExecutor:
# Clean up temp directory # Clean up temp directory
import shutil import shutil
shutil.rmtree(temp_dir, ignore_errors=True) shutil.rmtree(temp_dir, ignore_errors=True)
logger.info("results pulled successfully", sandbox=sandbox, archive=str(archive_path)) logger.info("results pulled successfully", sandbox=sandbox, archive=str(archive_path))
@@ -571,6 +590,7 @@ class ModuleExecutor:
self.terminate_sandbox(sandbox) self.terminate_sandbox(sandbox)
if input_dir and input_dir.exists(): if input_dir and input_dir.exists():
import shutil import shutil
shutil.rmtree(input_dir, ignore_errors=True) shutil.rmtree(input_dir, ignore_errors=True)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -669,4 +689,5 @@ class ModuleExecutor:
self.terminate_sandbox(container_id) self.terminate_sandbox(container_id)
if input_dir: if input_dir:
import shutil import shutil
shutil.rmtree(input_dir, ignore_errors=True) shutil.rmtree(input_dir, ignore_errors=True)
@@ -60,10 +60,15 @@ class ProjectSettings(BaseModel):
class RegistrySettings(BaseModel): class RegistrySettings(BaseModel):
"""Container registry configuration for module images.""" """Container registry configuration for module images.
#: Registry URL for pulling module images. By default, registry URL is empty (local-only mode). When empty,
url: str = Field(default="ghcr.io/fuzzinglabs") 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 to use when pulling images.
default_tag: str = Field(default="latest") default_tag: str = Field(default="latest")
+20
View File
@@ -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
]