diff --git a/butler.py b/butler.py index 36d4df72db7..815899b6cc6 100644 --- a/butler.py +++ b/butler.py @@ -97,11 +97,13 @@ def _setup_args_for_remote(parser): subparsers.add_parser('reboot', help='Reboot with `sudo reboot`.') + def _add_integration_tests_subparsers(toplevel_subparsers): """Adds a parser for the `integration_tests` command.""" toplevel_subparsers.add_parser( 'integration_tests', help='Run end-to-end integration tests.') - + + def _add_weights_fuzzer_subparser(weights_subparsers): """Adds a parser for the `weights fuzzer` command.""" parser = weights_subparsers.add_parser( @@ -236,6 +238,7 @@ def _add_weights_subparser(toplevel_subparsers): _add_weights_batches_subparser(subparsers) _add_weights_target_subparser(subparsers) + def _add_reproduce_subparser(toplevel_subparsers): """Adds a parser for the `reproduce` command.""" parser = toplevel_subparsers.add_parser( @@ -243,12 +246,21 @@ def _add_reproduce_subparser(toplevel_subparsers): parser.add_argument( '-c', '--config-dir', required=True, help='Path to application config.') parser.add_argument( - '-t', '--testcase-id', required=True, help='The testcase ID to reproduce.') + '-t', + '--testcase-id', + required=True, + help='The testcase ID to reproduce.') + def main(): """Parse the command-line args and invoke the right command.""" parser = _ArgumentParser( description='Butler is here to help you with command-line tasks.') + parser.add_argument( + '--local-logging', + action='store_true', + default=False, + help='Force logs to be local-only.') subparsers = parser.add_subparsers(dest='command') subparsers.add_parser( @@ -424,12 +436,12 @@ def main(): parser.print_help() return 0 - _setup() + _setup(args) command = importlib.import_module(f'local.butler.{args.command}') return command.execute(args) -def _setup(): +def _setup(args): """Set up configs and import paths.""" os.environ['ROOT_DIR'] = os.path.abspath('.') os.environ['PYTHONIOENCODING'] = 'UTF-8' @@ -438,6 +450,10 @@ def _setup(): from clusterfuzz._internal.base import modules modules.fix_module_search_paths() + if args.local_logging: + from clusterfuzz._internal.system import environment + environment.set_local_log_only() + if __name__ == '__main__': sys.exit(main()) diff --git a/src/clusterfuzz/_internal/bot/tasks/setup.py b/src/clusterfuzz/_internal/bot/tasks/setup.py index 0a46472bf20..3476060283f 100644 --- a/src/clusterfuzz/_internal/bot/tasks/setup.py +++ b/src/clusterfuzz/_internal/bot/tasks/setup.py @@ -49,6 +49,7 @@ _DATA_BUNDLE_SYNC_INTERVAL_IN_SECONDS = 6 * 60 * 60 _SYNC_FILENAME = '.sync' _TESTCASE_ARCHIVE_EXTENSION = '.zip' +_EXECUTABLE_PERMISSIONS = 0o750 def _set_timeout_value_from_user_upload(testcase_id, uworker_env): @@ -352,10 +353,12 @@ def _get_testcase_key_and_archive_status(testcase): def _is_testcase_minimized(testcase): + """Returns True if the testcase is minimized.""" return testcase.minimized_keys and testcase.minimized_keys != 'NA' def download_testcase(testcase_download_url, dst): + """Downloads a testcase from a signed url""" return storage.download_signed_url_to_file(testcase_download_url, dst) @@ -651,7 +654,7 @@ def _update_fuzzer( return False # Make fuzzer executable. - os.chmod(fuzzer_path, 0o750) + os.chmod(fuzzer_path, _EXECUTABLE_PERMISSIONS) # Cleanup unneeded archive. shell.remove_file(archive_path) @@ -890,3 +893,119 @@ def archive_testcase_and_dependencies_in_gcs(resource_list, testcase_path: str, shell.remove_file(zip_path) return archived, absolute_filename, zip_filename + + +def setup_local_testcase(testcase: data_types.Testcase) -> str | None: + """Sets up the testcase file locally. + + Args: + testcase: The Testcase object. + + Returns: + - str | None : The local file path to the testcase, or None on failure. + """ + try: + shell.clear_testcase_directories() + except Exception as e: + logs.error(f'Error clearing testcase directories: {e}') + return None + + try: + _, testcase_file_path = _get_testcase_file_and_path(testcase) + if testcase.minimized_keys and testcase.minimized_keys != 'NA': + blob_key = testcase.minimized_keys + else: + blob_key = testcase.fuzzed_keys + if not blobs.read_blob_to_disk(blob_key, testcase_file_path): + logs.error(f'Failed to download testcase from blobstore: {blob_key}') + # Returning None for path when download fails + return None + prepare_environment_for_testcase(testcase) + except Exception as e: + logs.error(f'Error setting up testcase locally: {e}') + return None + + return testcase_file_path + + +def setup_local_fuzzer(fuzzer_name: str) -> bool: + """Sets up the fuzzer binaries and environment. + + Args: + fuzzer_name: The name of the fuzzer to set up. + + Returns: + True if setup was successful, False otherwise. + """ + fuzzer = data_types.Fuzzer.query(data_types.Fuzzer.name == fuzzer_name).get() + if not fuzzer: + logs.error(f'Fuzzer {fuzzer_name} not found.') + return False + + if fuzzer.untrusted_content: + logs.warning('You are about to run an untrusted fuzzer locally. ' + 'This can be dangerous.') + + environment.set_value('UNTRUSTED_CONTENT', fuzzer.untrusted_content) + + if fuzzer.data_bundle_name: + logs.info( + f'Fuzzer {fuzzer_name} uses data bundle: {fuzzer.data_bundle_name}') + + if fuzzer.launcher_script: + logs.error('Fuzzers with launch script not supported yet.') + return False + + if fuzzer.builtin: + logs.info(f'Fuzzer {fuzzer_name} is builtin, no setup required.') + return True + + fuzzer_directory: str = get_fuzzer_directory(fuzzer.name) + + try: + if not shell.remove_directory(fuzzer_directory, recreate=True): + logs.error(f'Failed to clear fuzzer directory: {fuzzer_directory}') + return False + except Exception as e: + logs.error(f'Error clearing fuzzer directory {fuzzer_directory}: {e}') + return False + + archive_path = os.path.join(fuzzer_directory, fuzzer.filename) + if not blobs.read_blob_to_disk(fuzzer.blobstore_key, archive_path): + logs.error('Failed to download fuzzer archive from blobstore: ' + f'{fuzzer.blobstore_key}') + return False + + try: + with archive.open(archive_path) as reader: + reader.extract_all(fuzzer_directory) + except archive.ArchiveError as e: + logs.error(f'Failed to unpack fuzzer archive {fuzzer.filename}: {e}') + return False + except Exception as e: + logs.error( + f'Unexpected error unpacking fuzzer archive {fuzzer.filename}: {e}') + return False + finally: + if os.path.exists(archive_path): + try: + shell.remove_file(archive_path) + except Exception as e: + logs.warning( + f'Failed to remove temporary archive file {archive_path}: {e}') + + fuzzer_path = os.path.join(fuzzer_directory, fuzzer.executable_path) + if not os.path.exists(fuzzer_path): + logs.error( + f'Fuzzer executable {fuzzer.executable_path} not found in archive. ' + 'Check fuzzer configuration.') + return False + + try: + os.chmod(fuzzer_path, _EXECUTABLE_PERMISSIONS) + except OSError as e: + logs.error( + f'Failed to set permissions on fuzzer executable {fuzzer_path}: {e}') + return False + + return True diff --git a/src/clusterfuzz/_internal/system/environment.py b/src/clusterfuzz/_internal/system/environment.py index 6618a011907..66dce9615de 100644 --- a/src/clusterfuzz/_internal/system/environment.py +++ b/src/clusterfuzz/_internal/system/environment.py @@ -1001,6 +1001,12 @@ def set_bot_environment(): return True +def set_local_log_only(): + """Force logs to be local-only.""" + set_value('LOG_TO_GCP', '') + set_value('LOG_TO_CONSOLE', 'True') + + def set_tsan_max_history_size(): """Sets maximum history size for TSAN tool.""" tsan_options = get_value('TSAN_OPTIONS') diff --git a/src/clusterfuzz/_internal/tests/core/bot/tasks/setup_test.py b/src/clusterfuzz/_internal/tests/core/bot/tasks/setup_test.py index b0c0c3c97ba..6e39dbe6b1c 100644 --- a/src/clusterfuzz/_internal/tests/core/bot/tasks/setup_test.py +++ b/src/clusterfuzz/_internal/tests/core/bot/tasks/setup_test.py @@ -22,6 +22,7 @@ from clusterfuzz._internal.bot.tasks.utasks import uworker_io from clusterfuzz._internal.datastore import data_types from clusterfuzz._internal.protos import uworker_msg_pb2 +from clusterfuzz._internal.system import archive from clusterfuzz._internal.system import environment from clusterfuzz._internal.tests.test_libs import helpers from clusterfuzz._internal.tests.test_libs import test_utils @@ -235,3 +236,239 @@ def test_data_bundles(self): self.fuzzer_name) setup.update_fuzzer_and_data_bundles(setup_input) self.assertEqual(self.mock.update_data_bundle.call_count, 2) + + +class SetupLocalFuzzerTest(unittest.TestCase): + """Tests for the setup_local_fuzzer function.""" + + def setUp(self): + super().setUp() + helpers.patch_environ(self) + self.addCleanup(unittest.mock.patch.stopall) + + self.mock_logs = unittest.mock.patch.multiple( + 'local.butler.reproduce.logs', + error=unittest.mock.DEFAULT, + info=unittest.mock.DEFAULT, + warning=unittest.mock.DEFAULT).start() + + helpers.patch(self, [ + 'clusterfuzz._internal.datastore.data_types.Fuzzer.query', + 'clusterfuzz._internal.system.environment.set_value', + 'clusterfuzz._internal.bot.tasks.setup.get_fuzzer_directory', + 'clusterfuzz._internal.system.shell.remove_directory', + 'clusterfuzz._internal.google_cloud_utils.blobs.read_blob_to_disk', + 'clusterfuzz._internal.system.archive.open', + 'clusterfuzz._internal.system.shell.remove_file', + 'os.path.exists', + 'os.chmod', + ]) + + # Alias for clarity + self.mock_fuzzer_query = self.mock.query + self.mock_get = self.mock_fuzzer_query.return_value.get + + # Default return values for success paths + self.mock.get_fuzzer_directory.return_value = '/tmp/fuzzer_dir' + self.mock.remove_directory.return_value = True + self.mock.read_blob_to_disk.return_value = True + + self.mock_archive_reader = unittest.mock.create_autospec( + spec=archive.ArchiveReader, instance=True, spec_set=True) + self.mock.open.return_value.__enter__.return_value = self.mock_archive_reader + + # Common fuzzer object + self.fuzzer = data_types.Fuzzer( + name='test_fuzzer', + builtin=False, + data_bundle_name='test_bundle', + launcher_script=None, + filename='fuzzer.zip', + executable_path='fuzzer_exe', + blobstore_key='some_key') + self.mock_get.return_value = self.fuzzer + + def test_setup_fuzzer_builtin_success(self): + """Test successful setup of a builtin fuzzer.""" + self.fuzzer.builtin = True + self.assertTrue(setup.setup_local_fuzzer('builtin_fuzzer')) + self.mock.set_value.assert_called_once() + self.mock.remove_directory.assert_not_called() + self.mock_logs['info'].assert_any_call( + 'Fuzzer builtin_fuzzer is builtin, no setup required.') + + def test_setup_fuzzer_external_success(self): + """Test successful setup of an external fuzzer.""" + self.mock.exists.side_effect = [True, True] # archive, executable + self.assertTrue(setup.setup_local_fuzzer('external_fuzzer')) + self.mock.remove_directory.assert_called_once_with( + '/tmp/fuzzer_dir', recreate=True) + self.mock.read_blob_to_disk.assert_called_once_with( + 'some_key', '/tmp/fuzzer_dir/fuzzer.zip') + self.mock.open.assert_called_once_with('/tmp/fuzzer_dir/fuzzer.zip') + self.mock_archive_reader.extract_all.assert_called_once_with( + '/tmp/fuzzer_dir') + self.mock.remove_file.assert_called_once_with('/tmp/fuzzer_dir/fuzzer.zip') + self.mock.chmod.assert_called_once_with('/tmp/fuzzer_dir/fuzzer_exe', + setup._EXECUTABLE_PERMISSIONS) + + def test_fuzzer_not_found(self): + """Test when the fuzzer is not found in the database.""" + self.mock_get.return_value = None + self.assertFalse(setup.setup_local_fuzzer('nonexistent_fuzzer')) + self.mock_logs['error'].assert_called_with( + 'Fuzzer nonexistent_fuzzer not found.') + + def test_launcher_script_unsupported(self): + """Test that fuzzers with launcher scripts are not supported.""" + self.fuzzer.launcher_script = 'launcher.sh' + self.assertFalse(setup.setup_local_fuzzer('launcher_fuzzer')) + self.mock_logs['error'].assert_called_with( + 'Fuzzers with launch script not supported yet.') + + def test_remove_directory_fails(self): + """Test failure when clearing the fuzzer directory.""" + self.mock.remove_directory.return_value = False + self.assertFalse(setup.setup_local_fuzzer('external_fuzzer')) + self.mock_logs['error'].assert_called_with( + 'Failed to clear fuzzer directory: /tmp/fuzzer_dir') + + def test_remove_directory_exception(self): + """Test exception when clearing the fuzzer directory.""" + self.mock.remove_directory.side_effect = Exception('mock remove error') + self.assertFalse(setup.setup_local_fuzzer('external_fuzzer')) + self.mock_logs['error'].assert_called_with( + 'Error clearing fuzzer directory /tmp/fuzzer_dir: mock remove error') + + def test_download_archive_fails(self): + """Test failure when downloading the fuzzer archive.""" + self.mock.read_blob_to_disk.return_value = False + self.assertFalse(setup.setup_local_fuzzer('external_fuzzer')) + self.mock_logs['error'].assert_called_with( + 'Failed to download fuzzer archive from blobstore: some_key') + + def test_unpack_archive_fails_archiveerror(self): + """Test failure when unpacking the fuzzer archive with ArchiveError.""" + self.mock.open.side_effect = archive.ArchiveError('mock unpack error') + self.assertFalse(setup.setup_local_fuzzer('external_fuzzer')) + self.mock_logs['error'].assert_called_with( + 'Failed to unpack fuzzer archive fuzzer.zip: mock unpack error') + + def test_unpack_archive_fails_exception(self): + """Test failure when unpacking with a generic exception.""" + self.mock.open.side_effect = Exception('mock generic error') + self.assertFalse(setup.setup_local_fuzzer('external_fuzzer')) + self.mock_logs['error'].assert_called_with( + 'Unexpected error unpacking fuzzer archive fuzzer.zip: mock generic error' + ) + + def test_executable_not_found(self): + """Test when the executable is not found after unpacking.""" + self.mock.exists.side_effect = [True, False] # archive, executable + self.assertFalse(setup.setup_local_fuzzer('external_fuzzer')) + self.mock_logs['error'].assert_called_with( + 'Fuzzer executable fuzzer_exe not found in archive. Check fuzzer configuration.' + ) + + def test_chmod_fails(self): + """Test failure when setting permissions on the executable.""" + self.mock.exists.side_effect = [True, True] + self.mock.chmod.side_effect = OSError('mock chmod error') + self.assertFalse(setup.setup_local_fuzzer('external_fuzzer')) + self.mock_logs['error'].assert_called_with( + 'Failed to set permissions on fuzzer executable /tmp/fuzzer_dir/fuzzer_exe: mock chmod error' + ) + + +class SetupLocalTestcaseTest(unittest.TestCase): + """Tests for the setup_local_testcase function.""" + + def setUp(self): + super().setUp() + self.addCleanup(unittest.mock.patch.stopall) + self.mock_logs = unittest.mock.patch.multiple( + 'local.butler.reproduce.logs', + error=unittest.mock.DEFAULT, + info=unittest.mock.DEFAULT, + warning=unittest.mock.DEFAULT).start() + + helpers.patch(self, [ + 'clusterfuzz._internal.system.shell.clear_testcase_directories', + 'clusterfuzz._internal.bot.tasks.setup._get_testcase_file_and_path', + 'clusterfuzz._internal.google_cloud_utils.blobs.read_blob_to_disk', + 'clusterfuzz._internal.bot.tasks.setup.prepare_environment_for_testcase' + ]) + + self.testcase = data_types.Testcase( + fuzzed_keys='testcase_key', minimized_keys=None) + + def test_success_fuzzed_keys(self): + """Test successful local setup of a testcase.""" + self.mock._get_testcase_file_and_path.return_value = (unittest.mock.ANY, + '/tmp/testcase') + self.mock.read_blob_to_disk.return_value = True + + path = setup.setup_local_testcase(self.testcase) + self.assertEqual(path, '/tmp/testcase') + self.mock.clear_testcase_directories.assert_called_once() + self.mock.read_blob_to_disk.assert_called_once_with('testcase_key', + '/tmp/testcase') + self.mock.prepare_environment_for_testcase.assert_called_once_with( + self.testcase) + + def test_success_minimized_keys(self): + """Test successful local setup of a testcase with minimized keys.""" + self.testcase.minimized_keys = 'minimized_key' + self.mock._get_testcase_file_and_path.return_value = (unittest.mock.ANY, + '/tmp/testcase') + self.mock.read_blob_to_disk.return_value = True + + path = setup.setup_local_testcase(self.testcase) + self.assertEqual(path, '/tmp/testcase') + self.mock.clear_testcase_directories.assert_called_once() + self.mock.read_blob_to_disk.assert_called_once_with('minimized_key', + '/tmp/testcase') + self.mock.prepare_environment_for_testcase.assert_called_once_with( + self.testcase) + + def test_clear_directories_fails(self): + """Test handling an exception from clear_testcase_directories.""" + self.mock.clear_testcase_directories.side_effect = Exception( + 'mock clear error') + path = setup.setup_local_testcase(self.testcase) + self.assertIsNone(path) + self.mock_logs['error'].assert_called_with( + 'Error clearing testcase directories: mock clear error') + + def test_download_fails_fuzzed_keys(self): + """Test handling a download failure from read_blob_to_disk.""" + self.mock._get_testcase_file_and_path.return_value = (unittest.mock.ANY, + '/tmp/testcase') + self.mock.read_blob_to_disk.return_value = False + path = setup.setup_local_testcase(self.testcase) + self.assertIsNone(path) + self.mock_logs['error'].assert_called_with( + 'Failed to download testcase from blobstore: testcase_key') + + def test_download_fails_minimized_keys(self): + """Test handling a download failure from read_blob_to_disk with minimized keys.""" + self.testcase.minimized_keys = 'minimized_key' + self.mock._get_testcase_file_and_path.return_value = (unittest.mock.ANY, + '/tmp/testcase') + self.mock.read_blob_to_disk.return_value = False + path = setup.setup_local_testcase(self.testcase) + self.assertIsNone(path) + self.mock_logs['error'].assert_called_with( + 'Failed to download testcase from blobstore: minimized_key') + + def test_prepare_env_fails(self): + """Test handling an exception from prepare_environment_for_testcase.""" + self.mock._get_testcase_file_and_path.return_value = (unittest.mock.ANY, + '/tmp/testcase') + self.mock.read_blob_to_disk.return_value = True + self.mock.prepare_environment_for_testcase.side_effect = Exception( + 'mock prepare error') + path = setup.setup_local_testcase(self.testcase) + self.assertIsNone(path) + self.mock_logs['error'].assert_called_with( + 'Error setting up testcase locally: mock prepare error') diff --git a/src/clusterfuzz/_internal/tests/core/local/butler/reproduce_test.py b/src/clusterfuzz/_internal/tests/core/local/butler/reproduce_test.py index 2160a7306da..a08043a9551 100644 --- a/src/clusterfuzz/_internal/tests/core/local/butler/reproduce_test.py +++ b/src/clusterfuzz/_internal/tests/core/local/butler/reproduce_test.py @@ -11,243 +11,19 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +"""Tests for the reproduce butler.""" import argparse -import os import unittest from unittest import mock -from clusterfuzz._internal.bot import testcase_manager -from clusterfuzz._internal.bot.tasks import setup -from clusterfuzz._internal.bot.tasks.commands import update_environment_for_job -from clusterfuzz._internal.datastore import data_handler +from clusterfuzz._internal.crash_analysis.crash_result import CrashResult from clusterfuzz._internal.datastore import data_types -from clusterfuzz._internal.datastore.data_types import Fuzzer -from clusterfuzz._internal.datastore.data_types import Job -from clusterfuzz._internal.datastore.data_types import Testcase -from clusterfuzz._internal.google_cloud_utils import blobs -from clusterfuzz._internal.metrics import logs from clusterfuzz._internal.protos import uworker_msg_pb2 -from clusterfuzz._internal.system import archive -from clusterfuzz._internal.system import environment -from clusterfuzz._internal.system import shell from clusterfuzz._internal.tests.test_libs import helpers from local.butler import reproduce -class SetupFuzzerTest(unittest.TestCase): - """Tests for the _setup_fuzzer function.""" - - def setUp(self): - super().setUp() - helpers.patch_environ(self) - self.addCleanup(mock.patch.stopall) - - self.mock_logs = mock.patch.multiple( - 'local.butler.reproduce.logs', - error=mock.DEFAULT, - info=mock.DEFAULT, - warning=mock.DEFAULT).start() - - helpers.patch(self, [ - 'clusterfuzz._internal.datastore.data_types.Fuzzer.query', - 'clusterfuzz._internal.system.environment.set_value', - 'clusterfuzz._internal.bot.tasks.setup.get_fuzzer_directory', - 'clusterfuzz._internal.system.shell.remove_directory', - 'clusterfuzz._internal.google_cloud_utils.blobs.read_blob_to_disk', - 'clusterfuzz._internal.system.archive.open', - 'clusterfuzz._internal.system.shell.remove_file', - 'os.path.exists', - 'os.chmod', - ]) - - # Alias for clarity - self.mock_fuzzer_query = self.mock.query - self.mock_get = self.mock_fuzzer_query.return_value.get - - # Default return values for success paths - self.mock.get_fuzzer_directory.return_value = '/tmp/fuzzer_dir' - self.mock.remove_directory.return_value = True - self.mock.read_blob_to_disk.return_value = True - - self.mock_archive_reader = mock.MagicMock() - self.mock.open.return_value.__enter__.return_value = self.mock_archive_reader - - # Common mock fuzzer object - self.mock_fuzzer = mock.MagicMock(spec=Fuzzer) - self.mock_fuzzer.name = 'test_fuzzer' - self.mock_fuzzer.builtin = False - self.mock_fuzzer.data_bundle_name = 'test_bundle' - self.mock_fuzzer.launcher_script = None - self.mock_fuzzer.filename = 'fuzzer.zip' - self.mock_fuzzer.executable_path = 'fuzzer_exe' - self.mock_fuzzer.blobstore_key = 'some_key' - self.mock_get.return_value = self.mock_fuzzer - - def test_setup_fuzzer_builtin_success(self): - """Test successful setup of a builtin fuzzer.""" - self.mock_fuzzer.builtin = True - self.assertTrue(reproduce._setup_fuzzer('builtin_fuzzer')) - self.mock.set_value.assert_called_once() - self.mock.remove_directory.assert_not_called() - self.mock_logs['info'].assert_any_call( - 'Fuzzer builtin_fuzzer is builtin, no setup required.') - - def test_setup_fuzzer_external_success(self): - """Test successful setup of an external fuzzer.""" - self.mock.exists.side_effect = [True, True] # archive, executable - self.assertTrue(reproduce._setup_fuzzer('external_fuzzer')) - self.mock.remove_directory.assert_called_once_with( - '/tmp/fuzzer_dir', recreate=True) - self.mock.read_blob_to_disk.assert_called_once_with( - 'some_key', '/tmp/fuzzer_dir/fuzzer.zip') - self.mock.open.assert_called_once_with('/tmp/fuzzer_dir/fuzzer.zip') - self.mock_archive_reader.extract_all.assert_called_once_with( - '/tmp/fuzzer_dir') - self.mock.remove_file.assert_called_once_with('/tmp/fuzzer_dir/fuzzer.zip') - self.mock.chmod.assert_called_once_with('/tmp/fuzzer_dir/fuzzer_exe', - reproduce._EXECUTABLE_PERMISSIONS) - - def test_fuzzer_not_found(self): - """Test when the fuzzer is not found in the database.""" - self.mock_get.return_value = None - self.assertFalse(reproduce._setup_fuzzer('nonexistent_fuzzer')) - self.mock_logs['error'].assert_called_with( - 'Fuzzer nonexistent_fuzzer not found.') - - def test_launcher_script_unsupported(self): - """Test that fuzzers with launcher scripts are not supported.""" - self.mock_fuzzer.launcher_script = 'launcher.sh' - self.assertFalse(reproduce._setup_fuzzer('launcher_fuzzer')) - self.mock_logs['error'].assert_called_with( - 'Fuzzers with launch script not supported yet.') - - def test_remove_directory_fails(self): - """Test failure when clearing the fuzzer directory.""" - self.mock.remove_directory.return_value = False - self.assertFalse(reproduce._setup_fuzzer('external_fuzzer')) - self.mock_logs['error'].assert_called_with( - 'Failed to clear fuzzer directory: /tmp/fuzzer_dir') - - def test_remove_directory_exception(self): - """Test exception when clearing the fuzzer directory.""" - self.mock.remove_directory.side_effect = Exception('mock remove error') - self.assertFalse(reproduce._setup_fuzzer('external_fuzzer')) - self.mock_logs['error'].assert_called_with( - 'Error clearing fuzzer directory /tmp/fuzzer_dir: mock remove error') - - def test_download_archive_fails(self): - """Test failure when downloading the fuzzer archive.""" - self.mock.read_blob_to_disk.return_value = False - self.assertFalse(reproduce._setup_fuzzer('external_fuzzer')) - self.mock_logs['error'].assert_called_with( - 'Failed to download fuzzer archive from blobstore: some_key') - - def test_unpack_archive_fails_archiveerror(self): - """Test failure when unpacking the fuzzer archive with ArchiveError.""" - self.mock.open.side_effect = archive.ArchiveError('mock unpack error') - self.assertFalse(reproduce._setup_fuzzer('external_fuzzer')) - self.mock_logs['error'].assert_called_with( - 'Failed to unpack fuzzer archive fuzzer.zip: mock unpack error') - - def test_unpack_archive_fails_exception(self): - """Test failure when unpacking with a generic exception.""" - self.mock.open.side_effect = Exception('mock generic error') - self.assertFalse(reproduce._setup_fuzzer('external_fuzzer')) - self.mock_logs['error'].assert_called_with( - 'Unexpected error unpacking fuzzer archive fuzzer.zip: mock generic error' - ) - - def test_executable_not_found(self): - """Test when the executable is not found after unpacking.""" - self.mock.exists.side_effect = [True, False] # archive, executable - self.assertFalse(reproduce._setup_fuzzer('external_fuzzer')) - self.mock_logs['error'].assert_called_with( - 'Fuzzer executable fuzzer_exe not found in archive. Check fuzzer configuration.' - ) - - def test_chmod_fails(self): - """Test failure when setting permissions on the executable.""" - self.mock.exists.side_effect = [True, True] - self.mock.chmod.side_effect = OSError('mock chmod error') - self.assertFalse(reproduce._setup_fuzzer('external_fuzzer')) - self.mock_logs['error'].assert_called_with( - 'Failed to set permissions on fuzzer executable /tmp/fuzzer_dir/fuzzer_exe: mock chmod error' - ) - - -class SetupTestcaseLocallyTest(unittest.TestCase): - """Tests for the _setup_testcase_locally function.""" - - def setUp(self): - super().setUp() - self.addCleanup(mock.patch.stopall) - self.mock_logs = mock.patch.multiple( - 'local.butler.reproduce.logs', - error=mock.DEFAULT, - info=mock.DEFAULT, - warning=mock.DEFAULT).start() - - helpers.patch(self, [ - 'clusterfuzz._internal.system.shell.clear_testcase_directories', - 'clusterfuzz._internal.bot.tasks.setup._get_testcase_file_and_path', - 'clusterfuzz._internal.google_cloud_utils.blobs.read_blob_to_disk', - 'clusterfuzz._internal.bot.tasks.setup.prepare_environment_for_testcase' - ]) - - self.mock_testcase = mock.MagicMock(spec=Testcase) - self.mock_testcase.fuzzed_keys = 'testcase_key' - - def test_success(self): - """Test successful local setup of a testcase.""" - self.mock._get_testcase_file_and_path.return_value = (mock.ANY, - '/tmp/testcase') - self.mock.read_blob_to_disk.return_value = True - - ok, path = reproduce._setup_testcase_locally(self.mock_testcase) - self.assertTrue(ok) - self.assertEqual(path, '/tmp/testcase') - self.mock.clear_testcase_directories.assert_called_once() - self.mock.read_blob_to_disk.assert_called_once_with('testcase_key', - '/tmp/testcase') - self.mock.prepare_environment_for_testcase.assert_called_once_with( - self.mock_testcase) - - def test_clear_directories_fails(self): - """Test handling an exception from clear_testcase_directories.""" - self.mock.clear_testcase_directories.side_effect = Exception( - 'mock clear error') - ok, path = reproduce._setup_testcase_locally(self.mock_testcase) - self.assertFalse(ok) - self.assertIsNone(path) - self.mock_logs['error'].assert_called_with( - 'Error clearing testcase directories: mock clear error') - - def test_download_fails(self): - """Test handling a download failure from read_blob_to_disk.""" - self.mock._get_testcase_file_and_path.return_value = (mock.ANY, - '/tmp/testcase') - self.mock.read_blob_to_disk.return_value = False - ok, path = reproduce._setup_testcase_locally(self.mock_testcase) - self.assertFalse(ok) - self.assertIsNone(path) - self.mock_logs['error'].assert_called_with( - 'Failed to download testcase from blobstore: testcase_key') - - def test_prepare_env_fails(self): - """Test handling an exception from prepare_environment_for_testcase.""" - self.mock._get_testcase_file_and_path.return_value = (mock.ANY, - '/tmp/testcase') - self.mock.read_blob_to_disk.return_value = True - self.mock.prepare_environment_for_testcase.side_effect = Exception( - 'mock prepare error') - ok, path = reproduce._setup_testcase_locally(self.mock_testcase) - self.assertFalse(ok) - self.assertIsNone(path) - self.mock_logs['error'].assert_called_with( - 'Error setting up testcase locally: mock prepare error') - - class ReproduceTestcaseTest(unittest.TestCase): """Tests for the _reproduce_testcase function.""" @@ -266,8 +42,8 @@ def setUp(self): 'clusterfuzz._internal.system.environment.set_value', 'clusterfuzz._internal.system.environment.get_value', 'clusterfuzz._internal.bot.tasks.commands.update_environment_for_job', - 'local.butler.reproduce._setup_fuzzer', - 'local.butler.reproduce._setup_testcase_locally', + 'clusterfuzz._internal.bot.tasks.setup.setup_local_fuzzer', + 'clusterfuzz._internal.bot.tasks.setup.setup_local_testcase', 'clusterfuzz._internal.build_management.build_manager.setup_build', 'clusterfuzz._internal.bot.testcase_manager.check_for_bad_build', 'clusterfuzz._internal.bot.testcase_manager.test_for_crash_with_retries', @@ -275,37 +51,37 @@ def setUp(self): 'clusterfuzz._internal.bot.untrusted_runner.host.stub', ]) - self.mock_testcase = mock.MagicMock(spec=Testcase) - self.mock_testcase.job_type = 'test_job' - self.mock_testcase.fuzzer_name = 'test_fuzzer' - self.mock_testcase.crash_revision = 12345 - self.mock_job = mock.MagicMock(spec=Job) - self.mock.get_testcase_by_id.return_value = self.mock_testcase - self.mock.query.return_value.get.return_value = self.mock_job + self.testcase = data_types.Testcase( + job_type='test_job', fuzzer_name='test_fuzzer', crash_revision=12345) + self.testcase.get_fuzz_target = mock.Mock() + self.job = data_types.Job(name='test_job') + self.mock.get_testcase_by_id.return_value = self.testcase + self.mock.query.return_value.get.return_value = self.job - self.mock._setup_fuzzer.return_value = True - self.mock._setup_testcase_locally.return_value = (True, '/tmp/testcase') + self.mock.setup_local_fuzzer.return_value = True + self.mock.setup_local_testcase.return_value = '/tmp/testcase' - mock_build_result = mock.MagicMock(spec=uworker_msg_pb2.BuildData) - mock_build_result.is_bad_build = False - self.mock.check_for_bad_build.return_value = mock_build_result + build_result = uworker_msg_pb2.BuildData(is_bad_build=False) + self.mock.check_for_bad_build.return_value = build_result - self.mock_crash_result = mock.MagicMock() - self.mock_crash_result.is_crash.return_value = True - self.mock.test_for_crash_with_retries.return_value = self.mock_crash_result + # The crash result needs to be a real object, but we need to control the + # return value of is_crash(). + crash_result = CrashResult(1, 1, 'mock crash output') + crash_result.is_crash = mock.Mock(return_value=True) + self.mock.test_for_crash_with_retries.return_value = crash_result self.mock.test_for_reproducibility.return_value = True - self.mock.get_value.return_value = str(reproduce._DEFAULT_TEST_TIMEOUT) + self.mock.get_value.return_value = str(reproduce._DEFAULT_TEST_TIMEOUT) # pylint: disable=protected-access self.args = argparse.Namespace(testcase_id=123, config_dir='/foo') def test_success_crash_reproduces(self): """Test the full success path where the crash reproduces.""" - reproduce._reproduce_testcase(self.args) + reproduce._reproduce_testcase(self.args) # pylint: disable=protected-access self.mock.get_testcase_by_id.assert_called_once_with(123) - self.mock._setup_fuzzer.assert_called_once() - self.mock._setup_testcase_locally.assert_called_once() + self.mock.setup_local_fuzzer.assert_called_once() + self.mock.setup_local_testcase.assert_called_once() self.mock.setup_build.assert_called_once() self.mock.check_for_bad_build.assert_called_once() self.mock.test_for_crash_with_retries.assert_called_once() @@ -315,7 +91,7 @@ def test_success_crash_reproduces(self): def test_success_crash_not_reproduces(self): """Test the success path where the crash does not reproduce.""" self.mock.test_for_reproducibility.return_value = False - reproduce._reproduce_testcase(self.args) + reproduce._reproduce_testcase(self.args) # pylint: disable=protected-access self.mock.test_for_reproducibility.assert_called_once() self.mock_logs['info'].assert_any_call( 'The testcase does not reliably reproduce.') @@ -323,31 +99,31 @@ def test_success_crash_not_reproduces(self): def test_testcase_not_found(self): """Test that it exits when the testcase is not found.""" self.mock.get_testcase_by_id.return_value = None - reproduce._reproduce_testcase(self.args) + reproduce._reproduce_testcase(self.args) # pylint: disable=protected-access self.mock_logs['error'].assert_called_with( 'Testcase with ID 123 not found.') - self.mock._setup_fuzzer.assert_not_called() + self.mock.setup_local_fuzzer.assert_not_called() def test_job_not_found(self): """Test that it exits when the job is not found.""" self.mock.query.return_value.get.return_value = None - reproduce._reproduce_testcase(self.args) + reproduce._reproduce_testcase(self.args) # pylint: disable=protected-access self.mock_logs['error'].assert_called_with( - f'Job type {self.mock_testcase.job_type} not found for testcase.') - self.mock._setup_fuzzer.assert_not_called() + f'Job type {self.testcase.job_type} not found for testcase.') + self.mock.setup_local_fuzzer.assert_not_called() def test_setup_fuzzer_fails(self): """Test that it exits when fuzzer setup fails.""" - self.mock._setup_fuzzer.return_value = False - reproduce._reproduce_testcase(self.args) + self.mock.setup_local_fuzzer.return_value = False + reproduce._reproduce_testcase(self.args) # pylint: disable=protected-access self.mock_logs['error'].assert_called_with( - f'Failed to setup fuzzer {self.mock_testcase.fuzzer_name}. Exiting.') - self.mock._setup_testcase_locally.assert_not_called() + f'Failed to setup fuzzer {self.testcase.fuzzer_name}. Exiting.') + self.mock.setup_local_testcase.assert_not_called() def test_setup_testcase_fails(self): """Test that it exits when testcase setup fails.""" - self.mock._setup_testcase_locally.return_value = (False, None) - reproduce._reproduce_testcase(self.args) + self.mock.setup_local_testcase.return_value = None + reproduce._reproduce_testcase(self.args) # pylint: disable=protected-access self.mock_logs['error'].assert_called_with( 'Could not setup testcase locally. Exiting.') self.mock.setup_build.assert_not_called() @@ -355,36 +131,36 @@ def test_setup_testcase_fails(self): def test_setup_build_fails(self): """Test that it exits when build setup fails.""" self.mock.setup_build.side_effect = Exception('mock build error') - reproduce._reproduce_testcase(self.args) + reproduce._reproduce_testcase(self.args) # pylint: disable=protected-access self.mock_logs['error'].assert_called_with( f'Error setting up build for revision ' - f'{self.mock_testcase.crash_revision}: mock build error') + f'{self.testcase.crash_revision}: mock build error') self.mock.check_for_bad_build.assert_not_called() def test_bad_build(self): """Test that it exits when a bad build is detected.""" self.mock.check_for_bad_build.return_value.is_bad_build = True - reproduce._reproduce_testcase(self.args) + reproduce._reproduce_testcase(self.args) # pylint: disable=protected-access self.mock_logs['error'].assert_called_with('Bad build detected. Exiting.') self.mock.test_for_crash_with_retries.assert_not_called() def test_no_crash(self): """Test that it exits when the initial crash does not occur.""" self.mock.test_for_crash_with_retries.return_value.is_crash.return_value = False - reproduce._reproduce_testcase(self.args) + reproduce._reproduce_testcase(self.args) # pylint: disable=protected-access self.mock_logs['info'].assert_any_call('No crash occurred. Exiting.') self.mock.test_for_reproducibility.assert_not_called() def test_invalid_timeout_env(self): """Test when TEST_TIMEOUT environment variable is invalid.""" self.mock.get_value.return_value = 'invalid' - reproduce._reproduce_testcase(self.args) + reproduce._reproduce_testcase(self.args) # pylint: disable=protected-access self.mock_logs['warning'].assert_any_call( f"Invalid TEST_TIMEOUT value: invalid. " - f"Using default: {reproduce._DEFAULT_TEST_TIMEOUT}") + f"Using default: {reproduce._DEFAULT_TEST_TIMEOUT}") # pylint: disable=protected-access # Check that test_for_crash_with_retries was called with default timeout _, kwargs = self.mock.test_for_crash_with_retries.call_args - self.assertEqual(kwargs['test_timeout'], reproduce._DEFAULT_TEST_TIMEOUT) + self.assertEqual(kwargs['test_timeout'], reproduce._DEFAULT_TEST_TIMEOUT) # pylint: disable=protected-access if __name__ == '__main__': diff --git a/src/local/butler/reproduce.py b/src/local/butler/reproduce.py index e79292d687d..37f35ef6ea6 100644 --- a/src/local/butler/reproduce.py +++ b/src/local/butler/reproduce.py @@ -15,142 +15,34 @@ import argparse import os -from typing import Optional -from typing import Tuple from clusterfuzz._internal.bot import testcase_manager from clusterfuzz._internal.bot.fuzzers import init +from clusterfuzz._internal.bot.tasks import commands from clusterfuzz._internal.bot.tasks import setup -from clusterfuzz._internal.bot.tasks.commands import update_environment_for_job from clusterfuzz._internal.build_management import build_manager from clusterfuzz._internal.config import local_config from clusterfuzz._internal.datastore import data_handler from clusterfuzz._internal.datastore import data_types from clusterfuzz._internal.datastore import ndb_init -from clusterfuzz._internal.datastore.data_types import Fuzzer -from clusterfuzz._internal.datastore.data_types import Job -from clusterfuzz._internal.datastore.data_types import Testcase -from clusterfuzz._internal.google_cloud_utils import blobs from clusterfuzz._internal.metrics import logs from clusterfuzz._internal.protos import uworker_msg_pb2 -from clusterfuzz._internal.system import archive from clusterfuzz._internal.system import environment -from clusterfuzz._internal.system import shell _DEFAULT_TEST_TIMEOUT = 60 -_EXECUTABLE_PERMISSIONS = 0o750 -def _setup_fuzzer(fuzzer_name: str) -> bool: - """Sets up the fuzzer binaries and environment. +def _setup_reproduce(args) -> None: + """Sets up the environment for reproducing a testcase. Args: - fuzzer_name: The name of the fuzzer to set up. - - Returns: - True if setup was successful, False otherwise. - """ - fuzzer: Optional[Fuzzer] = data_types.Fuzzer.query( - data_types.Fuzzer.name == fuzzer_name).get() - if not fuzzer: - logs.error(f'Fuzzer {fuzzer_name} not found.') - return False - - environment.set_value('UNTRUSTED_CONTENT', fuzzer.untrusted_content) - - if fuzzer.data_bundle_name: - logs.info( - f'Fuzzer {fuzzer_name} uses data bundle: {fuzzer.data_bundle_name}') - - if fuzzer.launcher_script: - logs.error('Fuzzers with launch script not supported yet.') - return False - - if fuzzer.builtin: - logs.info(f'Fuzzer {fuzzer_name} is builtin, no setup required.') - return True - - fuzzer_directory: str = setup.get_fuzzer_directory(fuzzer.name) - - try: - if not shell.remove_directory(fuzzer_directory, recreate=True): - logs.error(f'Failed to clear fuzzer directory: {fuzzer_directory}') - return False - except Exception as e: - logs.error(f'Error clearing fuzzer directory {fuzzer_directory}: {e}') - return False - - archive_path = os.path.join(fuzzer_directory, fuzzer.filename) - if not blobs.read_blob_to_disk(fuzzer.blobstore_key, archive_path): - logs.error('Failed to download fuzzer archive from blobstore: ' - f'{fuzzer.blobstore_key}') - return False - - try: - with archive.open(archive_path) as reader: - reader.extract_all(fuzzer_directory) - except archive.ArchiveError as e: - logs.error(f'Failed to unpack fuzzer archive {fuzzer.filename}: {e}') - return False - except Exception as e: - logs.error( - f'Unexpected error unpacking fuzzer archive {fuzzer.filename}: {e}') - return False - finally: - if os.path.exists(archive_path): - try: - shell.remove_file(archive_path) - except Exception as e: - logs.warning( - f'Failed to remove temporary archive file {archive_path}: {e}') - - fuzzer_path = os.path.join(fuzzer_directory, fuzzer.executable_path) - if not os.path.exists(fuzzer_path): - logs.error( - f'Fuzzer executable {fuzzer.executable_path} not found in archive. ' - 'Check fuzzer configuration.') - return False - - try: - os.chmod(fuzzer_path, _EXECUTABLE_PERMISSIONS) - except OSError as e: - logs.error( - f'Failed to set permissions on fuzzer executable {fuzzer_path}: {e}') - return False - - return True - - -def _setup_testcase_locally(testcase: Testcase) -> Tuple[bool, Optional[str]]: - """Sets up the testcase file locally. - - Args: - testcase: The Testcase object. - - Returns: - A tuple containing: - - bool: True if the testcase was downloaded successfully. - - Optional[str]: The local file path to the testcase, or None on failure. + args: Parsed command-line arguments. """ - try: - shell.clear_testcase_directories() - except Exception as e: - logs.error(f'Error clearing testcase directories: {e}') - return False, None - - try: - _, testcase_file_path = setup._get_testcase_file_and_path(testcase) - if not blobs.read_blob_to_disk(testcase.fuzzed_keys, testcase_file_path): - logs.error('Failed to download testcase from blobstore: ' - f'{testcase.fuzzed_keys}') - # Returning None for path when download fails - return False, None - setup.prepare_environment_for_testcase(testcase) - except Exception as e: - logs.error(f'Error setting up testcase locally: {e}') - return False, None - - return True, testcase_file_path + os.environ['CONFIG_DIR_OVERRIDE'] = os.path.abspath(args.config_dir) + local_config.ProjectConfig().set_environment() + environment.set_bot_environment() + logs.configure('run_bot') + init.run() def _reproduce_testcase(args: argparse.Namespace) -> None: @@ -159,14 +51,12 @@ def _reproduce_testcase(args: argparse.Namespace) -> None: Args: args: Parsed command-line arguments. """ - testcase: Optional[Testcase] = data_handler.get_testcase_by_id( - args.testcase_id) + testcase = data_handler.get_testcase_by_id(args.testcase_id) if not testcase: logs.error(f'Testcase with ID {args.testcase_id} not found.') return - job: Optional[Job] = data_types.Job.query( - data_types.Job.name == testcase.job_type).get() + job = data_types.Job.query(data_types.Job.name == testcase.job_type).get() if not job: logs.error(f'Job type {testcase.job_type} not found for testcase.') return @@ -174,14 +64,14 @@ def _reproduce_testcase(args: argparse.Namespace) -> None: # The job name is not set in update_environment_for_job, # so it was needed to manually set it here. environment.set_value('JOB_NAME', job.name) - update_environment_for_job(job.get_environment_string()) + commands.update_environment_for_job(job.get_environment_string()) - if not _setup_fuzzer(testcase.fuzzer_name): + if not setup.setup_local_fuzzer(testcase.fuzzer_name): logs.error(f'Failed to setup fuzzer {testcase.fuzzer_name}. Exiting.') return - ok, testcase_file_path = _setup_testcase_locally(testcase) - if not ok or testcase_file_path is None: + testcase_file_path = setup.setup_local_testcase(testcase) + if testcase_file_path is None: logs.error('Could not setup testcase locally. Exiting.') return @@ -196,7 +86,7 @@ def _reproduce_testcase(args: argparse.Namespace) -> None: f'Error setting up build for revision {testcase.crash_revision}: {e}') return - bad_build_result: uworker_msg_pb2.BuildData = ( + bad_build_result: uworker_msg_pb2.BuildData = ( # pylint: disable=no-member testcase_manager.check_for_bad_build(job.name, testcase.crash_revision)) if bad_build_result.is_bad_build: logs.error('Bad build detected. Exiting.') @@ -255,13 +145,6 @@ def execute(args: argparse.Namespace) -> None: Args: args: Parsed command-line arguments. """ - os.environ['CONFIG_DIR_OVERRIDE'] = os.path.abspath(args.config_dir) - local_config.ProjectConfig().set_environment() - environment.set_bot_environment() - os.environ['LOG_TO_CONSOLE'] = 'True' - os.environ['LOG_TO_GCP'] = '' - logs.configure('run_bot') - init.run() - + _setup_reproduce(args) with ndb_init.context(): _reproduce_testcase(args)