mirror of
https://github.com/trailofbits/buttercup
synced 2026-06-21 14:11:39 +00:00
09f2980ff9
* Add optional tmpfs storage for fuzzing corpus Add support for storing the node-local fuzzing corpus in tmpfs (RAM-based filesystem) for improved I/O performance during fuzzing operations. Key changes: - Add CORPUS_TMPFS_PATH environment variable to configure tmpfs location - Add cross-filesystem safe operations (copy+delete fallback for EXDEV) - Update Corpus class to use tmpfs path when enabled while maintaining correct remote path calculation for rsync synchronization - Add Helm chart configuration using /dev/shm (requires no host setup) - Update fuzzer-bot, coverage-bot, merger-bot, seed-gen deployments The feature is disabled by default. Enable by setting global.volumes.corpusTmpfs.enabled=true in values.yaml. Uses hostPath to /dev/shm/buttercup-corpus which is available on Linux systems without requiring any Kubernetes host configuration.
535 lines
25 KiB
Python
535 lines
25 KiB
Python
import os
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, mock_open, patch
|
|
|
|
import pytest
|
|
|
|
from buttercup.common.node_local import (
|
|
TmpDir,
|
|
_get_root_path,
|
|
dir_to_remote_archive,
|
|
get_corpus_tmpfs_path,
|
|
is_corpus_tmpfs_enabled,
|
|
local_scratch_file,
|
|
lopen,
|
|
remote_scratch_file,
|
|
rename_atomically,
|
|
scratch_dir,
|
|
scratch_path,
|
|
temp_dir,
|
|
)
|
|
from buttercup.common.node_local import (
|
|
remote_archive_path as remote_archive_path_func,
|
|
)
|
|
from buttercup.common.node_local import (
|
|
remote_path as remote_path_func,
|
|
)
|
|
|
|
# Use this root path for all tests
|
|
TEST_ROOT_PATH = Path("/test/node/data/dir")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_env_settings():
|
|
"""Set up environment variables and common mocks."""
|
|
# Set NODE_DATA_DIR environment variable
|
|
with patch.dict(os.environ, {"NODE_DATA_DIR": str(TEST_ROOT_PATH)}):
|
|
# Mock Path.is_relative_to to handle test paths
|
|
with patch.object(Path, "is_relative_to", return_value=True):
|
|
# Mock os.path.abspath to avoid actual filesystem lookups
|
|
with patch("os.path.abspath", side_effect=lambda x: x):
|
|
# Mock Path.exists to avoid checking real files
|
|
with patch.object(Path, "exists", return_value=True):
|
|
# Mock Path.is_dir to avoid filesystem checks
|
|
with patch.object(Path, "is_dir", return_value=True):
|
|
# Mock Path.mkdir to avoid creating real directories
|
|
with patch.object(Path, "mkdir"):
|
|
yield
|
|
|
|
|
|
class TestNodeLocal:
|
|
"""Tests for node_local.py module."""
|
|
|
|
@patch("buttercup.common.node_local.node_local_path", str(TEST_ROOT_PATH))
|
|
def test_get_root_path(self):
|
|
"""Test that _get_root_path returns the correct path."""
|
|
path = _get_root_path()
|
|
assert path == TEST_ROOT_PATH
|
|
|
|
@patch("buttercup.common.node_local.node_local_path", None)
|
|
def test_get_root_path_no_env_var(self):
|
|
"""Test that _get_root_path raises an error when NODE_DATA_DIR is not set."""
|
|
with pytest.raises(AssertionError, match="NODE_DATA_DIR environment variable is not defined"):
|
|
_get_root_path()
|
|
|
|
def test_tmp_dir_init(self):
|
|
"""Test TmpDir initialization."""
|
|
path = Path("/tmp/test_dir")
|
|
tmp_dir = TmpDir(path)
|
|
assert tmp_dir.path == path
|
|
assert tmp_dir.commit is False
|
|
|
|
def test_tmp_dir_fspath(self):
|
|
"""Test TmpDir.__fspath__ returns a string."""
|
|
path = Path("/tmp/test_dir")
|
|
tmp_dir = TmpDir(path)
|
|
assert tmp_dir.__fspath__() == str(path)
|
|
|
|
@patch("tempfile.mkdtemp")
|
|
@patch("shutil.rmtree")
|
|
def test_temp_dir(self, mock_rmtree, mock_mkdtemp):
|
|
"""Test temp_dir context manager."""
|
|
root_path = Path("/test/root")
|
|
mock_mkdtemp.return_value = "/test/root/tmpxyz123"
|
|
|
|
# Test normal case where TmpDir.commit = False
|
|
with temp_dir(root_path) as d:
|
|
assert d.path == Path("/test/root/tmpxyz123")
|
|
assert d.commit is False
|
|
|
|
# Should call rmtree when commit is False
|
|
mock_rmtree.assert_called_once_with(Path("/test/root/tmpxyz123"), ignore_errors=True)
|
|
|
|
# Reset mocks
|
|
mock_rmtree.reset_mock()
|
|
|
|
# Test case where TmpDir.commit = True
|
|
with temp_dir(root_path) as d:
|
|
d.commit = True
|
|
|
|
# Should not call rmtree when commit is True
|
|
mock_rmtree.assert_not_called()
|
|
|
|
@patch("os.rename")
|
|
def test_rename_atomically_success(self, mock_rename):
|
|
"""Test rename_atomically success case."""
|
|
src = Path("/src/path")
|
|
dst = Path("/dst/path")
|
|
|
|
# Mock dst.parent.mkdir to avoid actually creating directories
|
|
with patch.object(Path, "mkdir"):
|
|
result = rename_atomically(src, dst)
|
|
|
|
# Check that rename was called with the correct arguments
|
|
mock_rename.assert_called_once_with(src, dst)
|
|
|
|
# Check the return value
|
|
assert result == dst
|
|
|
|
@patch("os.rename")
|
|
def test_rename_atomically_path_exists(self, mock_rename):
|
|
"""Test rename_atomically when the path already exists."""
|
|
src = Path("/src/path")
|
|
dst = Path("/dst/path")
|
|
|
|
# Mock os.rename to raise OSError with errno 39 (Directory exists)
|
|
mock_rename.side_effect = OSError(39, "Directory exists")
|
|
|
|
# Mock dst.parent.mkdir to avoid actually creating directories
|
|
with patch.object(Path, "mkdir"):
|
|
result = rename_atomically(src, dst)
|
|
|
|
# Check that rename was called with the correct arguments
|
|
mock_rename.assert_called_once_with(src, dst)
|
|
|
|
# Check the return value
|
|
assert result is None
|
|
|
|
@patch("os.rename")
|
|
def test_rename_atomically_other_error(self, mock_rename):
|
|
"""Test rename_atomically when another error occurs."""
|
|
src = Path("/src/path")
|
|
dst = Path("/dst/path")
|
|
|
|
# Mock os.rename to raise OSError with errno 2 (No such file or directory)
|
|
mock_rename.side_effect = OSError(2, "No such file or directory")
|
|
|
|
# Mock dst.parent.mkdir to avoid actually creating directories
|
|
with patch.object(Path, "mkdir"):
|
|
with pytest.raises(OSError):
|
|
rename_atomically(src, dst)
|
|
|
|
@patch("buttercup.common.node_local.node_local_path", str(TEST_ROOT_PATH))
|
|
def test_remote_path(self):
|
|
"""Test remote_path function."""
|
|
local_path = Path("/test/node/data/dir/sample/path")
|
|
|
|
# Mock is_relative_to to avoid filesystem checks
|
|
with patch.object(Path, "is_relative_to", return_value=True):
|
|
result = remote_path_func(local_path)
|
|
assert result == Path("/sample/path")
|
|
|
|
@patch("buttercup.common.node_local.node_local_path", str(TEST_ROOT_PATH))
|
|
def test_remote_path_not_relative(self):
|
|
"""Test remote_path with a path not relative to NODE_DATA_DIR."""
|
|
local_path = Path("/some/other/path")
|
|
|
|
# Override the is_relative_to to return false for this test
|
|
with patch.object(Path, "is_relative_to", return_value=False):
|
|
with pytest.raises(AssertionError, match="Input path .* must be relative to NODE_DATA_DIR"):
|
|
remote_path_func(local_path)
|
|
|
|
@patch("buttercup.common.node_local.remote_path")
|
|
def test_remote_archive_path(self, mock_remote_path):
|
|
"""Test remote_archive_path function."""
|
|
local_path = Path("/test/node/data/dir/sample/path")
|
|
mock_remote_path.return_value = Path("/sample/path")
|
|
|
|
result = remote_archive_path_func(local_path)
|
|
assert result == Path("/sample/path.tgz")
|
|
|
|
@patch("buttercup.common.node_local.node_local_path", str(TEST_ROOT_PATH))
|
|
def test_scratch_path(self):
|
|
"""Test scratch_path function."""
|
|
# Test when the scratch directory doesn't exist
|
|
with patch.object(Path, "exists", return_value=False):
|
|
with patch.object(Path, "mkdir"):
|
|
result = scratch_path()
|
|
assert result == Path("/test/node/data/dir/scratch")
|
|
|
|
# Test when the scratch directory exists
|
|
with patch.object(Path, "exists", return_value=True):
|
|
result = scratch_path()
|
|
assert result == Path("/test/node/data/dir/scratch")
|
|
|
|
@patch("buttercup.common.node_local.temp_dir")
|
|
@patch("buttercup.common.node_local.scratch_path")
|
|
def test_scratch_dir(self, mock_scratch_path, mock_temp_dir):
|
|
"""Test scratch_dir function."""
|
|
mock_scratch_path.return_value = Path("/test/node/data/dir/scratch")
|
|
mock_tmp_dir = TmpDir(Path("/test/node/data/dir/scratch/tmpdir"))
|
|
mock_temp_dir.return_value.__enter__.return_value = mock_tmp_dir
|
|
|
|
with scratch_dir() as result:
|
|
assert result == mock_tmp_dir
|
|
|
|
mock_temp_dir.assert_called_once_with(Path("/test/node/data/dir/scratch"))
|
|
|
|
def test_make_locally_available(self):
|
|
"""Test make_locally_available function."""
|
|
local_path = Path("/test/node/data/dir/sample/path")
|
|
remote_path_val = Path("/sample/path")
|
|
scratch_file_name = "/tmp/scratch_file"
|
|
|
|
# Mock functions to verify the correct flow
|
|
with patch("buttercup.common.node_local.remote_path", return_value=remote_path_val) as mock_remote_path:
|
|
# Create a context for builtins.open
|
|
with patch("builtins.open", mock_open(read_data=b"test data")) as mocked_open:
|
|
# Create a mock for scratch file
|
|
mock_scratch_file = MagicMock()
|
|
mock_scratch_file.name = scratch_file_name
|
|
mock_scratch_context = MagicMock()
|
|
mock_scratch_context.__enter__.return_value = mock_scratch_file
|
|
|
|
with patch(
|
|
"buttercup.common.node_local.local_scratch_file",
|
|
return_value=mock_scratch_context,
|
|
) as mock_local_scratch:
|
|
# Mock copyfileobj to avoid actual copying
|
|
with patch("shutil.copyfileobj") as mock_copy:
|
|
# Mock rename_atomically
|
|
with patch(
|
|
"buttercup.common.node_local.rename_atomically",
|
|
return_value=local_path,
|
|
) as mock_rename:
|
|
# Mock path operations
|
|
with patch.object(Path, "mkdir"):
|
|
with patch.object(Path, "exists", return_value=True):
|
|
# We'll create a manual implementation of the function
|
|
# that uses our mocks but avoids recursive calls
|
|
def test_impl():
|
|
# This is a simplified version of make_locally_available
|
|
path = local_path
|
|
rpath = mock_remote_path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with mock_local_scratch() as scratch_file:
|
|
with mocked_open(rpath, "rb") as remote_file:
|
|
mock_copy(remote_file, scratch_file)
|
|
temp_path = scratch_file.name
|
|
|
|
result = mock_rename(temp_path, path)
|
|
return result if result is not None else path
|
|
|
|
# Call our test implementation
|
|
result = test_impl()
|
|
|
|
# Verify the result
|
|
assert result == local_path
|
|
|
|
# Verify correct sequence of operations
|
|
mock_remote_path.assert_called_once_with(local_path)
|
|
mocked_open.assert_called_once_with(remote_path_val, "rb")
|
|
mock_copy.assert_called_once()
|
|
mock_rename.assert_called_once_with(scratch_file_name, local_path)
|
|
|
|
def test_remote_archive_to_dir(self):
|
|
"""Test remote_archive_to_dir function."""
|
|
local_path = Path("/test/node/data/dir/sample/path")
|
|
remote_archive_path_val = Path("/sample/path.tgz")
|
|
scratch_file_name = "/tmp/local_file.tgz"
|
|
temp_dir_path = Path("/tmp/extracted")
|
|
|
|
# Mock the archive path
|
|
with patch(
|
|
"buttercup.common.node_local.remote_archive_path",
|
|
return_value=remote_archive_path_val,
|
|
) as mock_rpath:
|
|
# Mock file operations
|
|
with patch("builtins.open", mock_open(read_data=b"test data")) as mocked_open:
|
|
# Create a mock for scratch file
|
|
mock_scratch_file = MagicMock()
|
|
mock_scratch_file.name = scratch_file_name
|
|
mock_scratch_context = MagicMock()
|
|
mock_scratch_context.__enter__.return_value = mock_scratch_file
|
|
|
|
with patch(
|
|
"buttercup.common.node_local.local_scratch_file",
|
|
return_value=mock_scratch_context,
|
|
) as mock_local_scratch:
|
|
# Mock copyfileobj to avoid actual copying
|
|
with patch("shutil.copyfileobj") as mock_copy:
|
|
# Mock tarfile operations
|
|
mock_tar = MagicMock()
|
|
mock_tar_context = MagicMock()
|
|
mock_tar_context.__enter__.return_value = mock_tar
|
|
|
|
with patch("tarfile.open", return_value=mock_tar_context) as mock_tarfile:
|
|
# Mock temp_dir
|
|
mock_tmp_dir = MagicMock()
|
|
mock_tmp_dir.path = temp_dir_path
|
|
mock_tmp_dir.commit = False
|
|
mock_tmp_dir_context = MagicMock()
|
|
mock_tmp_dir_context.__enter__.return_value = mock_tmp_dir
|
|
|
|
with patch(
|
|
"buttercup.common.node_local.scratch_dir",
|
|
return_value=mock_tmp_dir_context,
|
|
) as mock_scratch_dir:
|
|
# Mock rename_atomically
|
|
with patch(
|
|
"buttercup.common.node_local.rename_atomically",
|
|
return_value=local_path,
|
|
) as mock_rename:
|
|
# Mock path.exists
|
|
with patch.object(Path, "exists", return_value=False):
|
|
# Create a manual implementation that avoids recursive calls
|
|
def test_impl():
|
|
# Check if path exists (mocked to return False)
|
|
if Path(local_path).exists():
|
|
return local_path
|
|
|
|
# Get remote path
|
|
rpath = mock_rpath(local_path)
|
|
|
|
# Use mocked opens and scratch files
|
|
with mocked_open(rpath, "rb") as remote_file:
|
|
with mock_local_scratch() as scratch_file:
|
|
mock_copy(remote_file, scratch_file)
|
|
scratch_file.flush()
|
|
scratch_file.seek(0)
|
|
|
|
# Use mocked tarfile
|
|
with mock_tarfile(fileobj=scratch_file, mode="r:gz"):
|
|
# Extract to mocked tmp dir
|
|
with mock_scratch_dir() as tmp_dir:
|
|
# Extract all
|
|
mock_tar.extractall(path=tmp_dir.path)
|
|
|
|
# Rename
|
|
renamed_path = mock_rename(tmp_dir.path, local_path)
|
|
if renamed_path is not None:
|
|
tmp_dir.commit = True
|
|
|
|
return local_path
|
|
|
|
# Call our test implementation
|
|
result = test_impl()
|
|
|
|
# Verify the result
|
|
assert result == local_path
|
|
|
|
# Verify correct sequence of operations
|
|
mock_rpath.assert_called_once_with(local_path)
|
|
mocked_open.assert_called_once_with(remote_archive_path_val, "rb")
|
|
mock_copy.assert_called_once()
|
|
mock_tarfile.assert_called_once()
|
|
mock_tar.extractall.assert_called_once_with(path=temp_dir_path)
|
|
mock_rename.assert_called_once_with(temp_dir_path, local_path)
|
|
assert mock_tmp_dir.commit is True
|
|
|
|
def test_dir_to_remote_archive(self):
|
|
"""Test dir_to_remote_archive function."""
|
|
local_path = Path("/test/node/data/dir/sample/path")
|
|
|
|
# Mock is_dir to return True to pass the initial check
|
|
with patch.object(Path, "is_dir", return_value=True):
|
|
# Mock remote_archive_path to avoid recursion
|
|
with patch("buttercup.common.node_local.remote_archive_path") as mock_remote_archive_path:
|
|
mock_remote_archive_path.return_value = Path("/sample/path.tgz")
|
|
|
|
# Mock local_scratch_file to avoid file system access
|
|
with patch("buttercup.common.node_local.local_scratch_file") as mock_local_scratch_file:
|
|
mock_local_file = MagicMock()
|
|
mock_local_file.name = "/tmp/local_file.tgz"
|
|
mock_local_context = MagicMock()
|
|
mock_local_context.__enter__.return_value = mock_local_file
|
|
mock_local_scratch_file.return_value = mock_local_context
|
|
|
|
# Mock tarfile operations
|
|
mock_tar = MagicMock()
|
|
mock_tarfile_context = MagicMock()
|
|
mock_tarfile_context.__enter__.return_value = mock_tar
|
|
|
|
with patch("tarfile.open", return_value=mock_tarfile_context):
|
|
# Mock remote_scratch_file to avoid file system access
|
|
with patch("buttercup.common.node_local.remote_scratch_file") as mock_remote_scratch_file:
|
|
mock_remote_file = MagicMock()
|
|
mock_remote_file.name = "/tmp/remote_file.tgz"
|
|
mock_remote_context = MagicMock()
|
|
mock_remote_context.__enter__.return_value = mock_remote_file
|
|
mock_remote_scratch_file.return_value = mock_remote_context
|
|
|
|
# Mock shutil.copyfileobj to avoid actual copying
|
|
with patch("shutil.copyfileobj"):
|
|
# Mock rename_atomically
|
|
with patch("buttercup.common.node_local.rename_atomically") as mock_rename:
|
|
remote_archive_path_val = Path("/sample/path.tgz")
|
|
mock_rename.return_value = remote_archive_path_val
|
|
|
|
# Call the function
|
|
result = dir_to_remote_archive(local_path)
|
|
|
|
# Check the return value
|
|
assert result == remote_archive_path_val
|
|
|
|
# Reset mocks for the second test
|
|
mock_rename.reset_mock()
|
|
|
|
# Test when the directory already exists (rename_atomically returns None)
|
|
mock_rename.return_value = None
|
|
|
|
# Mock os.unlink to avoid actual file deletion
|
|
with patch("os.unlink") as mock_unlink:
|
|
result = dir_to_remote_archive(local_path)
|
|
|
|
# Check that unlink was called
|
|
mock_unlink.assert_called_once_with("/tmp/remote_file.tgz")
|
|
|
|
# Check the return value
|
|
assert result == remote_archive_path_val
|
|
|
|
def test_dir_to_remote_archive_not_dir(self):
|
|
"""Test dir_to_remote_archive when path is not a directory."""
|
|
local_path = Path("/test/node/data/dir/sample/file.txt")
|
|
|
|
# Override the is_dir fixture for this test
|
|
with patch.object(Path, "is_dir", return_value=False):
|
|
with pytest.raises(AssertionError, match="Local path .* must be a directory"):
|
|
dir_to_remote_archive(local_path)
|
|
|
|
def test_local_scratch_file(self):
|
|
"""Test local_scratch_file function."""
|
|
# We need to patch at the module level where NamedTemporaryFile is imported
|
|
# Setup our test path
|
|
test_scratch_path = Path("/test/node/data/dir/scratch")
|
|
|
|
# Setup our mock file
|
|
mock_file = MagicMock(name="mock_file")
|
|
|
|
# First patch the scratch_path function
|
|
with patch("buttercup.common.node_local.scratch_path", return_value=test_scratch_path):
|
|
|
|
@contextmanager
|
|
def mock_ntf(*args, **kwargs):
|
|
yield mock_file
|
|
|
|
# Then patch the NamedTemporaryFile at the module level where it's imported
|
|
with patch("buttercup.common.node_local.NamedTemporaryFile") as named_tmp_file_mock:
|
|
named_tmp_file_mock.side_effect = mock_ntf
|
|
|
|
# Call the function
|
|
with local_scratch_file(delete=False, suffix=".txt") as result:
|
|
# Verify the result
|
|
assert result is mock_file
|
|
|
|
# Verify the call to NamedTemporaryFile
|
|
named_tmp_file_mock.assert_called_once_with(dir=test_scratch_path, delete=False, suffix=".txt")
|
|
|
|
def test_remote_scratch_file(self):
|
|
"""Test remote_scratch_file function."""
|
|
# Setup test data
|
|
local_path = Path("/test/node/data/dir/sample/path")
|
|
remote_path_value = Path("/sample/path")
|
|
mock_file = MagicMock(name="mock_file")
|
|
|
|
# First patch the remote_path function
|
|
with patch("buttercup.common.node_local.remote_path", return_value=remote_path_value):
|
|
# Then patch the NamedTemporaryFile at the module level where it's imported
|
|
@contextmanager
|
|
def mock_ntf(*args, **kwargs):
|
|
yield mock_file
|
|
|
|
with patch("buttercup.common.node_local.NamedTemporaryFile") as named_tmp_file_mock:
|
|
named_tmp_file_mock.side_effect = mock_ntf
|
|
|
|
# Call the function
|
|
with remote_scratch_file(local_path, delete=False, suffix=".txt") as result:
|
|
# Verify the result
|
|
assert result is mock_file
|
|
|
|
# Verify the call to NamedTemporaryFile
|
|
named_tmp_file_mock.assert_called_once_with(dir=Path("/sample"), delete=False, suffix=".txt")
|
|
|
|
def test_lopen(self):
|
|
"""Test lopen function."""
|
|
local_path = Path("/test/node/data/dir/sample/file.txt")
|
|
mode = "rb"
|
|
|
|
# Mock make_locally_available to avoid actual file operations
|
|
with patch("buttercup.common.node_local.make_locally_available") as mock_mla:
|
|
# Mock builtins.open to avoid actual file operations and return a controlled mock
|
|
mock_file = MagicMock()
|
|
with patch("builtins.open", return_value=mock_file) as mock_open_func:
|
|
# Call the function
|
|
result = lopen(local_path, mode)
|
|
|
|
# Check that make_locally_available was called with the correct path
|
|
mock_mla.assert_called_once_with(local_path)
|
|
|
|
# Check that open was called with the correct arguments
|
|
mock_open_func.assert_called_once_with(local_path, mode)
|
|
|
|
# Check the return value
|
|
assert result == mock_file
|
|
|
|
|
|
class TestTmpfsCorpusConfiguration:
|
|
"""Tests for tmpfs corpus configuration functions."""
|
|
|
|
@patch("buttercup.common.node_local.corpus_tmpfs_path", None)
|
|
def test_is_corpus_tmpfs_enabled_false(self):
|
|
"""Test is_corpus_tmpfs_enabled when not configured."""
|
|
assert is_corpus_tmpfs_enabled() is False
|
|
|
|
@patch("buttercup.common.node_local.corpus_tmpfs_path", "/tmp/corpus")
|
|
def test_is_corpus_tmpfs_enabled_true(self):
|
|
"""Test is_corpus_tmpfs_enabled when configured."""
|
|
assert is_corpus_tmpfs_enabled() is True
|
|
|
|
@patch("buttercup.common.node_local.corpus_tmpfs_path", None)
|
|
def test_get_corpus_tmpfs_path_not_set(self):
|
|
"""Test get_corpus_tmpfs_path when not configured."""
|
|
assert get_corpus_tmpfs_path() is None
|
|
|
|
@patch("buttercup.common.node_local.corpus_tmpfs_path", "/tmp/corpus")
|
|
def test_get_corpus_tmpfs_path_set(self):
|
|
"""Test get_corpus_tmpfs_path when configured."""
|
|
result = get_corpus_tmpfs_path()
|
|
assert result == Path("/tmp/corpus")
|
|
|
|
|
|
# Cross-filesystem operation tests are in test_cross_filesystem.py
|
|
# to avoid interference with the autouse mock_env_settings fixture.
|