diff --git a/src/clusterfuzz/_internal/bot/fuzzers/utils.py b/src/clusterfuzz/_internal/bot/fuzzers/utils.py index a7494c0ce86..40cc1713556 100644 --- a/src/clusterfuzz/_internal/bot/fuzzers/utils.py +++ b/src/clusterfuzz/_internal/bot/fuzzers/utils.py @@ -14,6 +14,7 @@ """Fuzzer utils.""" import functools +import json import os import re import stat @@ -98,8 +99,53 @@ def is_fuzz_target(file_path, file_opener: Optional[Callable] = None): return False +def _get_manifest_fuzz_targets(path): + """Get list of fuzz targets from clusterfuzz_manifest.json if present.""" + manifest_path = os.path.join(path, 'clusterfuzz_manifest.json') + if not os.path.exists(manifest_path): + return None + + try: + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest = json.load(f) + except Exception as e: + logs.error(f'Failed to read {manifest_path}: {e}') + return None + + manifest_targets = manifest.get('fuzz_targets') + if manifest_targets is None: + return None + + if not isinstance(manifest_targets, list): + logs.error(f'fuzz_targets in {manifest_path} is not a list') + return None + + fuzz_target_paths = [] + for target_path in manifest_targets: + if not isinstance(target_path, str): + logs.error(f'Entry in fuzz_targets ({manifest_path}) is not a string: ' + f'{target_path}') + continue + file_path = os.path.join(path, target_path) + if not os.path.exists(file_path): + basename_path = os.path.join(path, os.path.basename(target_path)) + if os.path.exists(basename_path): + file_path = basename_path + + if os.path.exists(file_path) and not os.path.isdir(file_path): + fuzz_target_paths.append(file_path) + else: + logs.warning(f'Fuzz target {target_path} in {manifest_path} not found.') + + return fuzz_target_paths if fuzz_target_paths else None + + def get_fuzz_targets_local(path): """Get list of fuzz targets paths (local).""" + manifest_fuzz_targets = _get_manifest_fuzz_targets(path) + if manifest_fuzz_targets is not None: + return manifest_fuzz_targets + fuzz_target_paths = [] for root, _, files in shell.walk(path): diff --git a/src/clusterfuzz/_internal/build_management/build_archive.py b/src/clusterfuzz/_internal/build_management/build_archive.py index 0333be63a7f..6d61231dfe1 100644 --- a/src/clusterfuzz/_internal/build_management/build_archive.py +++ b/src/clusterfuzz/_internal/build_management/build_archive.py @@ -314,7 +314,7 @@ def __init__(self, self._manifest_fuzz_targets = None # The manifest may not exist for earlier versions of archives. In this # case, default to schema version 0. - manifest_path = CHROME_MANIFEST_FILENAME + manifest_path = os.path.join(reader.root_dir(), CHROME_MANIFEST_FILENAME) if not self.file_exists(manifest_path): self._archive_schema_version = default_archive_schema_version return diff --git a/src/clusterfuzz/_internal/tests/core/bot/fuzzers/utils_test.py b/src/clusterfuzz/_internal/tests/core/bot/fuzzers/utils_test.py index e3be4fe34b7..7ba01e4b3b2 100644 --- a/src/clusterfuzz/_internal/tests/core/bot/fuzzers/utils_test.py +++ b/src/clusterfuzz/_internal/tests/core/bot/fuzzers/utils_test.py @@ -13,6 +13,7 @@ # limitations under the License. """Tests for fuzzers.utils.""" +import json import os import shutil import tempfile @@ -148,3 +149,44 @@ def test_par(self): """Test par extension.""" self.assertEqual('/a/b.labels', utils.get_supporting_file('/a/b.par', '.labels')) + + +class GetFuzzTargetsLocalTest(unittest.TestCase): + """Tests for get_fuzz_targets_local.""" + + def setUp(self): + test_helpers.patch_environ(self) + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def _create_file(self, name, contents=b''): + path = os.path.join(self.temp_dir, name) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'wb') as f: + f.write(contents) + return path + + def test_manifest_targets_used(self): + """Test that clusterfuzz_manifest.json is used to find targets.""" + target_a = self._create_file('target_a', contents=b'LLVMFuzzerTestOneInput') + self._create_file('run_target_a_fuzzer', contents=b'LLVMFuzzerTestOneInput') + manifest_contents = json.dumps({ + 'archive_schema_version': 1, + 'fuzz_targets': ['target_a'] + }).encode('utf-8') + self._create_file('clusterfuzz_manifest.json', contents=manifest_contents) + + targets = utils.get_fuzz_targets_local(self.temp_dir) + self.assertEqual([target_a], targets) + + def test_manifest_missing_fallback(self): + """Test fallback to scanning when manifest is missing.""" + target_a = self._create_file( + 'target_a_fuzzer', contents=b'LLVMFuzzerTestOneInput') + target_b = self._create_file( + 'target_b_fuzzer', contents=b'LLVMFuzzerTestOneInput') + + targets = utils.get_fuzz_targets_local(self.temp_dir) + self.assertCountEqual([target_a, target_b], targets) diff --git a/src/clusterfuzz/_internal/tests/core/build_management/build_archive_test.py b/src/clusterfuzz/_internal/tests/core/build_management/build_archive_test.py index f27c4d5b334..55d0d189c8b 100644 --- a/src/clusterfuzz/_internal/tests/core/build_management/build_archive_test.py +++ b/src/clusterfuzz/_internal/tests/core/build_management/build_archive_test.py @@ -445,6 +445,25 @@ def test_manifest_fuzz_targets_list(self): # Ensure list_members was never called for fuzz target discovery. self.mock_archive_reader.list_members.assert_not_called() + def test_manifest_fuzz_targets_with_root_dir(self): + """Tests that manifest is read when archive has a root directory prefix.""" + self.mock_archive_reader.root_dir.return_value = 'build' + + def _mock_file_exists(_, path): + return path == 'build/clusterfuzz_manifest.json' + + self.mock.file_exists.side_effect = _mock_file_exists + self._generate_manifest({ + 'archive_schema_version': 1, + 'fuzz_targets': ['out/build/my_fuzzer', 'out/build/other_fuzzer'] + }) + + test_archive = build_archive.ChromeBuildArchive(self.mock_archive_reader) + + self.assertEqual(test_archive.archive_schema_version(), 1) + self.assertCountEqual(test_archive.list_fuzz_targets(), + ['my_fuzzer', 'other_fuzzer']) + def test_manifest_fuzz_targets_invalid(self): """Tests that invalid fuzz_targets (e.g. dict) in the manifest are ignored and we fallback to discovery."""