From a0ba1ed8a36204b79faf51e86635a95c2c6cc1ac Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Thu, 23 Jul 2026 02:55:41 +0000 Subject: [PATCH 1/4] Introduce FuzzerRunOutputData and utils --- src/clusterfuzz/_internal/base/utils.py | 12 ++++++ .../_internal/bot/testcase_manager.py | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/clusterfuzz/_internal/base/utils.py b/src/clusterfuzz/_internal/base/utils.py index 5cd25d55a49..bb59e5c8651 100644 --- a/src/clusterfuzz/_internal/base/utils.py +++ b/src/clusterfuzz/_internal/base/utils.py @@ -36,6 +36,7 @@ from clusterfuzz._internal.config import local_config from clusterfuzz._internal.metrics import logs from clusterfuzz._internal.system import environment +from clusterfuzz._internal.system import shell try: import psutil @@ -658,6 +659,17 @@ def read_data_from_file(file_path, eval_data=True, default=None): return None +def read_data_from_file_and_remove(file_path, eval_data=False, default=None): + """Reads file content and removes the file after read""" + if not file_path or not os.path.exists(file_path): + return default + + try: + return read_data_from_file(file_path, eval_data=eval_data, default=default) + finally: + shell.remove_file(file_path) + + def remove_prefix(string, prefix): """Strips the prefix from a string.""" if string.startswith(prefix): diff --git a/src/clusterfuzz/_internal/bot/testcase_manager.py b/src/clusterfuzz/_internal/bot/testcase_manager.py index 67ac58a428d..7e818f6285b 100644 --- a/src/clusterfuzz/_internal/bot/testcase_manager.py +++ b/src/clusterfuzz/_internal/bot/testcase_manager.py @@ -15,6 +15,7 @@ import base64 import collections +import dataclasses import datetime import os import re @@ -356,6 +357,43 @@ class Crash( fields.""" +@dataclasses.dataclass +class FuzzerRunOutputData: + """Output metadata for a single fuzzer run stored in memory or a temp file.""" + output_or_file_path: str | bytes + crash_path: str | None = None + return_code: int = 0 + log_time: datetime.datetime | None = None + + def _is_file_path(self) -> bool: + """Returns True if output_or_file_path is an existing file path.""" + return isinstance(self.output_or_file_path, str) and os.path.exists( + self.output_or_file_path) + + def get_output(self) -> str | None: + """Reads or decodes the fuzzer output text.""" + if not self.output_or_file_path: + return None + + if self._is_file_path(): + output = utils.read_data_from_file_and_remove( + self.output_or_file_path, eval_data=False) + return None if output is None else output.decode( + 'utf-8', errors='replace') + + if isinstance(self.output_or_file_path, bytes): + return self.output_or_file_path.decode('utf-8', errors='replace') + + return self.output_or_file_path + + +@dataclasses.dataclass +class TestcaseRunResult: + """Result of a testcase execution queued for processing.""" + crash: Crash | None = None + fuzzer_run_output_data: FuzzerRunOutputData | None = None + + def get_resource_paths(output): """Read the urls from the output.""" resource_paths = set() From c3853365e00bd9bfc27dae4382b7a757b0f99412 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Thu, 23 Jul 2026 17:45:57 +0000 Subject: [PATCH 2/4] Refactor FuzzerRunOutputData output logic Split output_or_file_path into _output and _file_path in FuzzerRunOutputData. Introduce from_file_path and from_memory factory classmethods to replace the single parameter initialization. --- .../_internal/bot/testcase_manager.py | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/src/clusterfuzz/_internal/bot/testcase_manager.py b/src/clusterfuzz/_internal/bot/testcase_manager.py index 7e818f6285b..78eddd2c8cf 100644 --- a/src/clusterfuzz/_internal/bot/testcase_manager.py +++ b/src/clusterfuzz/_internal/bot/testcase_manager.py @@ -360,31 +360,53 @@ class Crash( @dataclasses.dataclass class FuzzerRunOutputData: """Output metadata for a single fuzzer run stored in memory or a temp file.""" - output_or_file_path: str | bytes + _output: str | bytes | None = None + _file_path: str | None = None crash_path: str | None = None return_code: int = 0 log_time: datetime.datetime | None = None - def _is_file_path(self) -> bool: - """Returns True if output_or_file_path is an existing file path.""" - return isinstance(self.output_or_file_path, str) and os.path.exists( - self.output_or_file_path) + @classmethod + def from_file_path(cls, + file_path: str, + crash_path: str | None = None, + return_code: int = 0, + log_time: datetime.datetime | None = None): + return cls( + _file_path=file_path, + crash_path=crash_path, + return_code=return_code, + log_time=log_time) + + @classmethod + def from_memory(cls, + output: str | bytes, + crash_path: str | None = None, + return_code: int = 0, + log_time: datetime.datetime | None = None): + return cls( + _output=output, + crash_path=crash_path, + return_code=return_code, + log_time=log_time) def get_output(self) -> str | None: """Reads or decodes the fuzzer output text.""" - if not self.output_or_file_path: - return None - - if self._is_file_path(): + if self._file_path: + if not os.path.exists(self._file_path): + return None output = utils.read_data_from_file_and_remove( - self.output_or_file_path, eval_data=False) + self._file_path, eval_data=False) return None if output is None else output.decode( 'utf-8', errors='replace') - if isinstance(self.output_or_file_path, bytes): - return self.output_or_file_path.decode('utf-8', errors='replace') + if not self._output: + return None + + if isinstance(self._output, bytes): + return self._output.decode('utf-8', errors='replace') - return self.output_or_file_path + return self._output @dataclasses.dataclass From d448d204637f3080604c4f71a162146c3d84b306 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Tue, 28 Jul 2026 17:46:56 +0000 Subject: [PATCH 3/4] Nits: Improves Documentation --- src/clusterfuzz/_internal/base/utils.py | 12 +++++++++++- src/clusterfuzz/_internal/bot/testcase_manager.py | 9 ++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/clusterfuzz/_internal/base/utils.py b/src/clusterfuzz/_internal/base/utils.py index bb59e5c8651..0701d95777c 100644 --- a/src/clusterfuzz/_internal/base/utils.py +++ b/src/clusterfuzz/_internal/base/utils.py @@ -660,7 +660,17 @@ def read_data_from_file(file_path, eval_data=True, default=None): def read_data_from_file_and_remove(file_path, eval_data=False, default=None): - """Reads file content and removes the file after read""" + """Reads file content and removes the file after reading. + + Args: + file_path: Path to the file to read. + eval_data: Whether to evaluate file content as a Python literal. + default: Value to return if file is empty, missing, or unreadable. + + Returns: + The file content (or evaluated object if eval_data is True), or `default` + if reading fails or file does not exist. + """ if not file_path or not os.path.exists(file_path): return default diff --git a/src/clusterfuzz/_internal/bot/testcase_manager.py b/src/clusterfuzz/_internal/bot/testcase_manager.py index 78eddd2c8cf..9e8fa00ded1 100644 --- a/src/clusterfuzz/_internal/bot/testcase_manager.py +++ b/src/clusterfuzz/_internal/bot/testcase_manager.py @@ -359,7 +359,14 @@ class Crash( @dataclasses.dataclass class FuzzerRunOutputData: - """Output metadata for a single fuzzer run stored in memory or a temp file.""" + """Output metadata for a single fuzzer run. + + - This class should be constructed either via `from_file_path()` or + `from_output()` - directly using the constructor is not recommended. + + - `_output` and `_file_path` are mutually exclusive (oneof); exactly one of + them should be set. + """ _output: str | bytes | None = None _file_path: str | None = None crash_path: str | None = None From fb12af2ed9773f7cfb461bffe41c955a059ceb57 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Tue, 28 Jul 2026 18:02:22 +0000 Subject: [PATCH 4/4] Caches read from file read while keeping the delete functionality --- .../_internal/bot/testcase_manager.py | 10 ++--- .../tests/core/bot/testcase_manager_test.py | 44 +++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/clusterfuzz/_internal/bot/testcase_manager.py b/src/clusterfuzz/_internal/bot/testcase_manager.py index 9e8fa00ded1..52892d06e5d 100644 --- a/src/clusterfuzz/_internal/bot/testcase_manager.py +++ b/src/clusterfuzz/_internal/bot/testcase_manager.py @@ -400,18 +400,16 @@ def from_memory(cls, def get_output(self) -> str | None: """Reads or decodes the fuzzer output text.""" if self._file_path: - if not os.path.exists(self._file_path): - return None output = utils.read_data_from_file_and_remove( self._file_path, eval_data=False) - return None if output is None else output.decode( - 'utf-8', errors='replace') + self._output = output if output is not None else None + self._file_path = None - if not self._output: + if self._output is None: return None if isinstance(self._output, bytes): - return self._output.decode('utf-8', errors='replace') + self._output = self._output.decode('utf-8', errors='replace') return self._output diff --git a/src/clusterfuzz/_internal/tests/core/bot/testcase_manager_test.py b/src/clusterfuzz/_internal/tests/core/bot/testcase_manager_test.py index 739b09f8335..0ae51f0935d 100644 --- a/src/clusterfuzz/_internal/tests/core/bot/testcase_manager_test.py +++ b/src/clusterfuzz/_internal/tests/core/bot/testcase_manager_test.py @@ -1013,3 +1013,47 @@ def test_blackbox_fuzzer_no_target(self): testcase_manager.preprocess_testcase_manager(blackbox_testcase, uworker_input) self.assertFalse(uworker_input.HasField('fuzz_target')) + + +class FuzzerRunOutputDataTest(fake_filesystem_unittest.TestCase): + """Tests for FuzzerRunOutputData output retrieval and file cleanup behavior.""" + + def setUp(self): + test_helpers.patch_environ(self) + test_utils.set_up_pyfakefs(self) + + def test_get_output_from_memory_str(self): + """Tests that get_output returns in-memory string output repeatably.""" + output_data = testcase_manager.FuzzerRunOutputData.from_memory( + output='test output') + self.assertEqual(output_data.get_output(), 'test output') + self.assertEqual(output_data.get_output(), 'test output') + + def test_get_output_from_memory_bytes(self): + """Tests that get_output decodes and caches in-memory bytes output as string.""" + output_data = testcase_manager.FuzzerRunOutputData.from_memory( + output=b'test bytes output') + self.assertEqual(output_data.get_output(), 'test bytes output') + self.assertEqual(output_data.get_output(), 'test bytes output') + + def test_get_output_from_file_path(self): + """Tests that get_output reads file content, deletes the file, and caches output for subsequent calls.""" + file_path = '/tmp/fuzzer_output.txt' + self.fs.create_file(file_path, contents='file content') + + output_data = testcase_manager.FuzzerRunOutputData.from_file_path(file_path) + self.assertEqual(output_data.get_output(), 'file content') + self.assertFalse(os.path.exists(file_path)) + self.assertEqual(output_data.get_output(), 'file content') + + def test_get_output_from_missing_file_path(self): + """Tests that get_output returns None when the backed file path does not exist.""" + file_path = '/tmp/nonexistent.txt' + output_data = testcase_manager.FuzzerRunOutputData.from_file_path(file_path) + self.assertIsNone(output_data.get_output()) + self.assertIsNone(output_data.get_output()) + + def test_get_output_empty(self): + """Tests that get_output returns None when neither output nor file path is set.""" + output_data = testcase_manager.FuzzerRunOutputData() + self.assertIsNone(output_data.get_output())