Skip to content

Commit 435969a

Browse files
committed
Skip RPATH patching on Schema v1 builds
1 parent b9081eb commit 435969a

3 files changed

Lines changed: 296 additions & 3 deletions

File tree

src/clusterfuzz/_internal/build_management/build_archive.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ def __init__(self, reader: archive.ArchiveReader):
6666
self._reader = reader
6767
self._fuzz_targets = None
6868

69+
@abc.abstractmethod
70+
def archive_schema_version(self) -> int:
71+
"""Returns the schema version number for this archive."""
72+
raise NotImplementedError
73+
6974
def list_members(self) -> List[archive.ArchiveMemberInfo]:
7075
return self._reader.list_members()
7176

@@ -162,6 +167,14 @@ class DefaultBuildArchive(BuildArchive):
162167
"""Default class for handling builds. This should work with everything.
163168
"""
164169

170+
def __init__(self, reader: archive.ArchiveReader):
171+
super().__init__(reader)
172+
self._fuzz_targets = {}
173+
174+
@override
175+
def archive_schema_version(self) -> int:
176+
return 0
177+
165178
def get_path_for_target(self, fuzz_target: str) -> str:
166179
"""Returns the path in the archive of the fuzz_target if found.
167180
This is needed because target name normalization means we're losing
@@ -344,8 +357,8 @@ def root_dir(self) -> str:
344357
self._root_dir = super().root_dir() # pylint: disable=attribute-defined-outside-init
345358
return self._root_dir
346359

360+
@override
347361
def archive_schema_version(self) -> int:
348-
"""Returns the schema version number for this archive."""
349362
return self._archive_schema_version
350363

351364
@override

src/clusterfuzz/_internal/build_management/build_manager.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151
# File name for storing current build revision.
5252
REVISION_FILE_NAME = 'REVISION'
5353

54+
# File name for storing the archive schema version retrieved during unpacking.
55+
# The version is derived from clusterfuzz_manifest.json if it exists in the
56+
# archive, otherwise it defaults to 0.
57+
SCHEMA_VERSION_FILE_NAME = '.schema_version'
58+
5459
# Various build type mapping strings.
5560
BUILD_TYPE_SUBSTRINGS = [
5661
'-beta', '-stable', '-debug', '-release', '-symbolized', '-extended_stable'
@@ -350,6 +355,7 @@ def __init__(self,
350355
# Every fetched build is a release one, except when SymbolizedBuild
351356
# explicitly downloads a debug build
352357
self._build_type = 'release'
358+
self._schema_version = None
353359

354360
def _reset_cwd(self):
355361
"""Reset current working directory. Needed to clean up build
@@ -378,6 +384,48 @@ def _pre_setup(self):
378384
environment.set_value(self.env_prefix + 'APP_PATH', '')
379385
environment.set_value(self.env_prefix + 'APP_PATH_DEBUG', '')
380386

387+
def _schema_version_path(self):
388+
"""Returns the path to the schema version metadata file in `build_dir`."""
389+
return os.path.join(self.build_dir, SCHEMA_VERSION_FILE_NAME)
390+
391+
def _read_schema_version_from_disk(self) -> int | None:
392+
"""Reads the schema version from `SCHEMA_VERSION_FILE_NAME` in `build_dir`.
393+
394+
Returns None if the file is missing or cannot be read, so callers can
395+
distinguish "no version recorded yet" from a recorded version of 0.
396+
"""
397+
schema_version_path = self._schema_version_path()
398+
if not os.path.exists(schema_version_path):
399+
return None
400+
try:
401+
return int(utils.read_data_from_file(schema_version_path) or 0)
402+
except Exception as e:
403+
logs.warning(
404+
f'Failed to read schema version from {schema_version_path}: {e}')
405+
return None
406+
407+
def _write_schema_version(self, schema_version):
408+
"""Writes schema version to `SCHEMA_VERSION_FILE_NAME` in `build_dir`."""
409+
schema_version_path = self._schema_version_path()
410+
try:
411+
utils.write_data_to_file(str(schema_version), schema_version_path)
412+
except Exception as e:
413+
logs.warning(
414+
f'Failed to write schema version to {schema_version_path}: {e}')
415+
416+
def _get_schema_version(self):
417+
"""Gets the schema version of the build, preferring the in-memory value and
418+
falling back to `SCHEMA_VERSION_FILE_NAME` in `build_dir`. Defaults to 0
419+
when no version has been recorded.
420+
421+
Note: For `SymbolizedBuild`, this naively looks for `.schema_version` at
422+
`build_dir` and assumes both release and debug builds share the same schema
423+
version. If they are different, the release build's schema version is used.
424+
"""
425+
if self._schema_version is None:
426+
self._schema_version = self._read_schema_version_from_disk()
427+
return self._schema_version or 0
428+
381429
def _patch_rpath(self, binary_path, instrumented_library_paths):
382430
"""Patch rpaths of a binary to point to instrumented libraries"""
383431
rpaths = get_rpaths(binary_path)
@@ -419,8 +467,13 @@ def _post_setup_success(self, update_revision=True):
419467

420468
# Update rpaths if necessary (for e.g. instrumented libraries).
421469
instrumented_library_paths = environment.get_instrumented_libraries_paths()
422-
if instrumented_library_paths:
423-
self._patch_rpaths(instrumented_library_paths)
470+
if not instrumented_library_paths:
471+
return
472+
if self._get_schema_version() > 0:
473+
logs.info('Skipping RPATH patching for schema v1+ build.')
474+
return
475+
476+
self._patch_rpaths(instrumented_library_paths)
424477

425478
@contextlib.contextmanager
426479
def _download_and_open_build_archive(self, base_build_dir: str,
@@ -563,6 +616,25 @@ def _unpack_build(self,
563616
fuzz_target=fuzz_target_to_unpack,
564617
trusted=trusted)
565618

619+
schema_version = build.archive_schema_version()
620+
621+
# Sync in-memory schema version with disk if we haven't read it yet.
622+
# This helps detect mismatches in shared build dirs (e.g.,
623+
# SymbolizedBuild).
624+
if self._schema_version is None:
625+
self._schema_version = self._read_schema_version_from_disk()
626+
627+
if self._schema_version is None:
628+
# First time unpacking: record the schema version.
629+
self._write_schema_version(schema_version)
630+
self._schema_version = schema_version
631+
elif self._schema_version != schema_version:
632+
# Following unpack: flag if the new archive version doesn't match.
633+
logs.error(
634+
f'Schema version mismatch for build in {self.build_dir}. '
635+
f'Existing version is {self._schema_version}, but new archive '
636+
f'{build_url} has version {schema_version}.')
637+
566638
_emit_job_build_retrieval_metric(unpack_start_time, 'unpack',
567639
self._build_type)
568640

src/clusterfuzz/_internal/tests/core/build_management/build_manager_test.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,6 +1580,33 @@ def test_patch_rpaths_existing_msan(self):
15801580
rpaths = build_manager.get_rpaths(os.environ['APP_PATH'])
15811581
self.assertListEqual(['/msan/lib', '/msan/usr/lib'], rpaths)
15821582

1583+
def test_patch_rpaths_schema_v1(self):
1584+
"""Tests that RPATH is not patched when schema version is > 0."""
1585+
1586+
def mock_unpack_build_schema_v1(actual_self,
1587+
base_build_dir,
1588+
build_dir,
1589+
url,
1590+
http_build_url=None):
1591+
self.mock_unpack_build('rpath_new', actual_self, base_build_dir,
1592+
build_dir, url, http_build_url)
1593+
schema_version_path = os.path.join(build_dir,
1594+
build_manager.SCHEMA_VERSION_FILE_NAME)
1595+
utils.write_data_to_file('1', schema_version_path)
1596+
return True
1597+
1598+
self.mock._unpack_build.side_effect = mock_unpack_build_schema_v1
1599+
1600+
build = build_manager.RegularBuild(self.base_build_dir, 1337,
1601+
'chained-origins')
1602+
self.assertTrue(build.setup())
1603+
self.assertEqual(
1604+
os.path.join(self.base_build_dir, 'revisions', 'app'),
1605+
os.environ['APP_PATH'])
1606+
1607+
rpaths = build_manager.get_rpaths(os.environ['APP_PATH'])
1608+
self.assertListEqual([], rpaths)
1609+
15831610

15841611
class SortBuildUrlsByRevisionTest(unittest.TestCase):
15851612
"""Test _sort_build_urls_by_revision."""
@@ -1969,3 +1996,184 @@ def test_is_discovery_non_fuzzing(self):
19691996
self.mock.is_engine_fuzzer_job.return_value = False
19701997
build = build_manager.Build('/base', 1, build_prefix='', fuzz_target=None)
19711998
self.assertFalse(build.is_discovery)
1999+
2000+
2001+
class GetSchemaVersionTest(fake_filesystem_unittest.TestCase):
2002+
"""Tests _get_schema_version resolution."""
2003+
2004+
def setUp(self):
2005+
fake_filesystem_unittest.TestCase.setUp(self)
2006+
self.setUpPyfakefs()
2007+
self.base_build_dir = '/base_build_dir'
2008+
self.fs.create_dir(self.base_build_dir)
2009+
2010+
def test_regular_build(self):
2011+
"""Tests reading schema version for regular build."""
2012+
build = build_manager.RegularBuild(self.base_build_dir, 1337,
2013+
'gs://dummy/url.zip')
2014+
self.fs.create_file(
2015+
os.path.join(build.build_dir, '.schema_version'), contents='1')
2016+
self.assertEqual(build._get_schema_version(), 1)
2017+
2018+
def test_symbolized_build(self):
2019+
"""Tests reading schema version for symbolized build."""
2020+
build = build_manager.SymbolizedBuild(self.base_build_dir, 1337,
2021+
'gs://dummy/release.zip',
2022+
'gs://dummy/debug.zip')
2023+
self.fs.create_file(
2024+
os.path.join(build.build_dir, '.schema_version'), contents='2')
2025+
self.assertEqual(build._get_schema_version(), 2)
2026+
2027+
def test_missing_file(self):
2028+
"""Tests that 0 is returned if file is missing."""
2029+
build = build_manager.RegularBuild(self.base_build_dir, 1337,
2030+
'gs://dummy/url.zip')
2031+
self.assertEqual(build._get_schema_version(), 0)
2032+
2033+
def test_get_schema_version_caching(self):
2034+
"""Tests that _get_schema_version caches results."""
2035+
build = build_manager.RegularBuild(self.base_build_dir, 1337,
2036+
'gs://dummy/url.zip')
2037+
self.fs.create_file(
2038+
os.path.join(build.build_dir, '.schema_version'), contents='1')
2039+
2040+
with mock.patch(
2041+
'clusterfuzz._internal.base.utils.read_data_from_file',
2042+
return_value='1') as mock_read_call:
2043+
# First call should trigger read.
2044+
v1 = build._get_schema_version()
2045+
2046+
self.assertEqual(v1, 1)
2047+
2048+
# Second call should use cache.
2049+
v2 = build._get_schema_version()
2050+
2051+
self.assertEqual(v2, 1)
2052+
self.assertEqual(mock_read_call.call_count, 1)
2053+
2054+
# Clear call history
2055+
mock_read_call.reset_mock()
2056+
2057+
# Call again, should still be cached.
2058+
v3 = build._get_schema_version()
2059+
2060+
self.assertEqual(v3, 1)
2061+
self.assertEqual(mock_read_call.call_count, 0)
2062+
2063+
2064+
class UnpackBuildSchemaVersionTest(fake_filesystem_unittest.TestCase):
2065+
"""Tests schema version handling during _unpack_build."""
2066+
2067+
def setUp(self):
2068+
test_utils.set_up_pyfakefs(self)
2069+
test_helpers.patch(self, [
2070+
'clusterfuzz._internal.build_management.build_manager._make_space',
2071+
'clusterfuzz._internal.system.shell.clear_temp_directory',
2072+
'clusterfuzz._internal.build_management.build_manager.Build._open_build_archive',
2073+
])
2074+
self.mock._make_space.return_value = True
2075+
2076+
test_helpers.patch_environ(self)
2077+
os.environ['BUILDS_DIR'] = '/builds'
2078+
os.environ['FAIL_RETRIES'] = '1'
2079+
os.environ['APP_NAME'] = FAKE_APP_NAME
2080+
os.environ['JOB_NAME'] = 'job'
2081+
2082+
self.base_build_dir = '/base_build_dir'
2083+
self.fs.create_dir(self.base_build_dir)
2084+
2085+
self.build = build_manager.RegularBuild(self.base_build_dir, 1337,
2086+
'gs://dummy/url.zip')
2087+
# build_dir for RegularBuild is base_build_dir/revisions
2088+
self.fs.create_dir(self.build.build_dir)
2089+
2090+
def mock_archive(self, schema_version):
2091+
archive_mock = mock.MagicMock()
2092+
archive_mock.unpacked_size.return_value = 100
2093+
archive_mock.archive_schema_version.return_value = schema_version
2094+
archive_mock.list_fuzz_targets.return_value = []
2095+
return archive_mock
2096+
2097+
def test_first_unpack_writes_version(self):
2098+
"""Tests that the first unpack writes the schema version."""
2099+
archive = self.mock_archive(1)
2100+
self.mock._open_build_archive.return_value.__enter__.return_value = archive
2101+
schema_file = os.path.join(self.build.build_dir, '.schema_version')
2102+
2103+
result = self.build._unpack_build(self.base_build_dir, self.build.build_dir,
2104+
'gs://dummy/url.zip')
2105+
2106+
self.assertTrue(result)
2107+
self.assertTrue(os.path.exists(schema_file))
2108+
self.assertEqual(utils.read_data_from_file(schema_file), 1)
2109+
self.assertEqual(self.build._schema_version, 1)
2110+
2111+
def test_second_unpack_same_version(self):
2112+
"""Tests that subsequent unpack with same version does not overwrite and doesn't log error."""
2113+
schema_file = os.path.join(self.build.build_dir, '.schema_version')
2114+
self.fs.create_file(schema_file, contents='1')
2115+
self.build._schema_version = 1
2116+
2117+
archive = self.mock_archive(1)
2118+
self.mock._open_build_archive.return_value.__enter__.return_value = archive
2119+
2120+
with mock.patch.object(self.build, '_write_schema_version') as mock_write:
2121+
with mock.patch(
2122+
'clusterfuzz._internal.metrics.logs.error') as mock_log_error:
2123+
result = self.build._unpack_build(
2124+
self.base_build_dir, self.build.build_dir, 'gs://dummy/url.zip')
2125+
2126+
self.assertTrue(result)
2127+
mock_write.assert_not_called()
2128+
mock_log_error.assert_not_called()
2129+
2130+
def test_second_unpack_different_version_logs_error(self):
2131+
"""Tests that subsequent unpack with different version logs error and does not overwrite."""
2132+
schema_file = os.path.join(self.build.build_dir, '.schema_version')
2133+
self.fs.create_file(schema_file, contents='1')
2134+
self.build._schema_version = 1
2135+
2136+
archive = self.mock_archive(2) # Second archive has version 2
2137+
self.mock._open_build_archive.return_value.__enter__.return_value = archive
2138+
2139+
with mock.patch.object(self.build, '_write_schema_version') as mock_write:
2140+
with mock.patch(
2141+
'clusterfuzz._internal.metrics.logs.error') as mock_log_error:
2142+
result = self.build._unpack_build(
2143+
self.base_build_dir, self.build.build_dir, 'gs://dummy/url.zip')
2144+
2145+
self.assertTrue(result)
2146+
mock_write.assert_not_called()
2147+
mock_log_error.assert_called_once()
2148+
error_msg = mock_log_error.call_args[0][0]
2149+
self.assertIn('Schema version mismatch', error_msg)
2150+
self.assertIn('1', error_msg) # Existing
2151+
self.assertIn('2', error_msg) # New
2152+
2153+
def test_unpack_reads_from_disk_if_not_in_memory(self):
2154+
"""Tests that unpack reads from disk if _schema_version is not in memory."""
2155+
build = build_manager.SymbolizedBuild(self.base_build_dir, 1337,
2156+
'gs://dummy/release.zip',
2157+
'gs://dummy/debug.zip')
2158+
self.fs.create_dir(build.build_dir)
2159+
self.fs.create_dir(build.release_build_dir)
2160+
self.fs.create_dir(build.debug_build_dir)
2161+
2162+
schema_file = os.path.join(build.build_dir, '.schema_version')
2163+
self.fs.create_file(schema_file, contents='1')
2164+
build._schema_version = None # Not in memory
2165+
2166+
archive = self.mock_archive(1)
2167+
self.mock._open_build_archive.return_value.__enter__.return_value = archive
2168+
2169+
with mock.patch.object(build, '_write_schema_version') as mock_write:
2170+
with mock.patch(
2171+
'clusterfuzz._internal.metrics.logs.error') as mock_log_error:
2172+
# Unpack debug build (simulating it running after release build, but with clean memory)
2173+
result = build._unpack_build(self.base_build_dir, build.debug_build_dir,
2174+
'gs://dummy/debug.zip')
2175+
2176+
self.assertTrue(result)
2177+
mock_write.assert_not_called()
2178+
self.assertEqual(build._schema_version, 1) # Should be synced to memory
2179+
mock_log_error.assert_not_called()

0 commit comments

Comments
 (0)