| # Copyright 2021 Google LLC |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # 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. |
| """Module for interacting with the ClusterFuzz deployment.""" |
| import logging |
| import os |
| import sys |
| import urllib.error |
| import urllib.request |
| |
| import config_utils |
| import continuous_integration |
| import filestore |
| import filestore_utils |
| import http_utils |
| import get_coverage |
| import repo_manager |
| |
| # pylint: disable=wrong-import-position,import-error |
| sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| import utils |
| |
| |
| class BaseClusterFuzzDeployment: |
| """Base class for ClusterFuzz deployments.""" |
| |
| def __init__(self, config, workspace): |
| self.config = config |
| self.workspace = workspace |
| self.ci_system = continuous_integration.get_ci(config) |
| |
| def download_latest_build(self): |
| """Downloads the latest build from ClusterFuzz. |
| |
| Returns: |
| A path to where the OSS-Fuzz build was stored, or None if it wasn't. |
| """ |
| raise NotImplementedError('Child class must implement method.') |
| |
| def upload_build(self, commit): |
| """Uploads the build with the given commit sha to the filestore.""" |
| raise NotImplementedError('Child class must implement method.') |
| |
| def download_corpus(self, target_name, corpus_dir): |
| """Downloads the corpus for |target_name| from ClusterFuzz to |corpus_dir|. |
| |
| Returns: |
| A path to where the OSS-Fuzz build was stored, or None if it wasn't. |
| """ |
| raise NotImplementedError('Child class must implement method.') |
| |
| def upload_crashes(self): |
| """Uploads crashes in |crashes_dir| to filestore.""" |
| raise NotImplementedError('Child class must implement method.') |
| |
| def upload_corpus(self, target_name, corpus_dir, replace=False): # pylint: disable=no-self-use,unused-argument |
| """Uploads the corpus for |target_name| to filestore.""" |
| raise NotImplementedError('Child class must implement method.') |
| |
| def upload_coverage(self): |
| """Uploads the coverage report to the filestore.""" |
| raise NotImplementedError('Child class must implement method.') |
| |
| def get_coverage(self, repo_path): |
| """Returns the project coverage object for the project.""" |
| raise NotImplementedError('Child class must implement method.') |
| |
| |
| def _make_empty_dir_if_nonexistent(path): |
| """Makes an empty directory at |path| if it does not exist.""" |
| os.makedirs(path, exist_ok=True) |
| |
| |
| class ClusterFuzzLite(BaseClusterFuzzDeployment): |
| """Class representing a deployment of ClusterFuzzLite.""" |
| |
| COVERAGE_NAME = 'latest' |
| LATEST_BUILD_WINDOW = 3 |
| |
| def __init__(self, config, workspace): |
| super().__init__(config, workspace) |
| self.filestore = filestore_utils.get_filestore(self.config) |
| |
| def download_latest_build(self): |
| if os.path.exists(self.workspace.clusterfuzz_build): |
| # This path is necessary because download_latest_build can be called |
| # multiple times.That is the case because it is called only when we need |
| # to see if a bug is novel, i.e. until we want to check a bug is novel we |
| # don't want to waste time calling this, but therefore this method can be |
| # called if multiple bugs are found. |
| return self.workspace.clusterfuzz_build |
| |
| repo_dir = self.ci_system.repo_dir() |
| if not repo_dir: |
| raise RuntimeError('Repo checkout does not exist.') |
| |
| _make_empty_dir_if_nonexistent(self.workspace.clusterfuzz_build) |
| repo = repo_manager.RepoManager(repo_dir) |
| |
| # Builds are stored by commit, so try the latest |LATEST_BUILD_WINDOW| |
| # commits before the current. |
| # TODO(ochang): If API usage becomes an issue, this can be optimized by the |
| # filestore accepting a list of filenames to try. |
| for old_commit in repo.get_commit_list('HEAD^', |
| limit=self.LATEST_BUILD_WINDOW): |
| logging.info('Trying to downloading previous build %s.', old_commit) |
| build_name = self._get_build_name(old_commit) |
| try: |
| if self.filestore.download_build(build_name, |
| self.workspace.clusterfuzz_build): |
| logging.info('Done downloading previus build.') |
| return self.workspace.clusterfuzz_build |
| |
| logging.info('Build for %s does not exist.', old_commit) |
| except Exception as err: # pylint: disable=broad-except |
| logging.error('Could not download build for %s because of: %s', |
| old_commit, err) |
| |
| return None |
| |
| def download_corpus(self, target_name, corpus_dir): |
| _make_empty_dir_if_nonexistent(corpus_dir) |
| logging.info('Downloading corpus for %s to %s.', target_name, corpus_dir) |
| corpus_name = self._get_corpus_name(target_name) |
| try: |
| self.filestore.download_corpus(corpus_name, corpus_dir) |
| logging.info('Done downloading corpus. Contains %d elements.', |
| len(os.listdir(corpus_dir))) |
| except Exception as err: # pylint: disable=broad-except |
| logging.error('Failed to download corpus for target: %s. Error: %s', |
| target_name, str(err)) |
| return corpus_dir |
| |
| def _get_build_name(self, name): |
| return f'{self.config.sanitizer}-{name}' |
| |
| def _get_corpus_name(self, target_name): # pylint: disable=no-self-use |
| """Returns the name of the corpus artifact.""" |
| return target_name |
| |
| def _get_crashes_artifact_name(self): # pylint: disable=no-self-use |
| """Returns the name of the crashes artifact.""" |
| return 'current' |
| |
| def upload_corpus(self, target_name, corpus_dir, replace=False): |
| """Upload the corpus produced by |target_name|.""" |
| logging.info('Uploading corpus in %s for %s.', corpus_dir, target_name) |
| name = self._get_corpus_name(target_name) |
| try: |
| self.filestore.upload_corpus(name, corpus_dir, replace=replace) |
| logging.info('Done uploading corpus.') |
| except Exception as error: # pylint: disable=broad-except |
| logging.error('Failed to upload corpus for target: %s. Error: %s.', |
| target_name, error) |
| |
| def upload_build(self, commit): |
| """Upload the build produced by CIFuzz as the latest build.""" |
| logging.info('Uploading latest build in %s.', self.workspace.out) |
| build_name = self._get_build_name(commit) |
| try: |
| result = self.filestore.upload_build(build_name, self.workspace.out) |
| logging.info('Done uploading latest build.') |
| return result |
| except Exception as error: # pylint: disable=broad-except |
| logging.error('Failed to upload latest build: %s. Error: %s', |
| self.workspace.out, error) |
| |
| def upload_crashes(self): |
| """Uploads crashes.""" |
| if not os.listdir(self.workspace.artifacts): |
| logging.info('No crashes in %s. Not uploading.', self.workspace.artifacts) |
| return |
| |
| crashes_artifact_name = self._get_crashes_artifact_name() |
| |
| logging.info('Uploading crashes in %s.', self.workspace.artifacts) |
| try: |
| self.filestore.upload_crashes(crashes_artifact_name, |
| self.workspace.artifacts) |
| logging.info('Done uploading crashes.') |
| except Exception as error: # pylint: disable=broad-except |
| logging.error('Failed to upload crashes. Error: %s', error) |
| |
| def upload_coverage(self): |
| """Uploads the coverage report to the filestore.""" |
| self.filestore.upload_coverage(self.COVERAGE_NAME, |
| self.workspace.coverage_report) |
| |
| def get_coverage(self, repo_path): |
| """Returns the project coverage object for the project.""" |
| try: |
| if not self.filestore.download_coverage( |
| self.COVERAGE_NAME, self.workspace.clusterfuzz_coverage): |
| logging.error('Could not download coverage.') |
| return None |
| return get_coverage.FilesystemCoverage( |
| repo_path, self.workspace.clusterfuzz_coverage) |
| except (get_coverage.CoverageError, filestore.FilestoreError): |
| logging.error('Could not get coverage.') |
| return None |
| |
| |
| class OSSFuzz(BaseClusterFuzzDeployment): |
| """The OSS-Fuzz ClusterFuzz deployment.""" |
| |
| # Location of clusterfuzz builds on GCS. |
| CLUSTERFUZZ_BUILDS = 'clusterfuzz-builds' |
| |
| # Zip file name containing the corpus. |
| CORPUS_ZIP_NAME = 'public.zip' |
| |
| def get_latest_build_name(self): |
| """Gets the name of the latest OSS-Fuzz build of a project. |
| |
| Returns: |
| A string with the latest build version or None. |
| """ |
| version_file = ( |
| f'{self.config.oss_fuzz_project_name}-{self.config.sanitizer}' |
| '-latest.version') |
| version_url = utils.url_join(utils.GCS_BASE_URL, self.CLUSTERFUZZ_BUILDS, |
| self.config.oss_fuzz_project_name, |
| version_file) |
| try: |
| response = urllib.request.urlopen(version_url) |
| except urllib.error.HTTPError: |
| logging.error('Error getting latest build version for %s from: %s.', |
| self.config.oss_fuzz_project_name, version_url) |
| return None |
| return response.read().decode() |
| |
| def download_latest_build(self): |
| """Downloads the latest OSS-Fuzz build from GCS. |
| |
| Returns: |
| A path to where the OSS-Fuzz build was stored, or None if it wasn't. |
| """ |
| if os.path.exists(self.workspace.clusterfuzz_build): |
| # This function can be called multiple times, don't download the build |
| # again. |
| return self.workspace.clusterfuzz_build |
| |
| _make_empty_dir_if_nonexistent(self.workspace.clusterfuzz_build) |
| |
| latest_build_name = self.get_latest_build_name() |
| if not latest_build_name: |
| return None |
| |
| logging.info('Downloading latest build.') |
| oss_fuzz_build_url = utils.url_join(utils.GCS_BASE_URL, |
| self.CLUSTERFUZZ_BUILDS, |
| self.config.oss_fuzz_project_name, |
| latest_build_name) |
| if http_utils.download_and_unpack_zip(oss_fuzz_build_url, |
| self.workspace.clusterfuzz_build): |
| logging.info('Done downloading latest build.') |
| return self.workspace.clusterfuzz_build |
| |
| return None |
| |
| def upload_build(self, commit): # pylint: disable=no-self-use |
| """Noop Implementation of upload_build.""" |
| logging.info('Not uploading latest build because on OSS-Fuzz.') |
| |
| def upload_corpus(self, target_name, corpus_dir, replace=False): # pylint: disable=no-self-use,unused-argument |
| """Noop Implementation of upload_corpus.""" |
| logging.info('Not uploading corpus because on OSS-Fuzz.') |
| |
| def upload_crashes(self): # pylint: disable=no-self-use |
| """Noop Implementation of upload_crashes.""" |
| logging.info('Not uploading crashes because on OSS-Fuzz.') |
| |
| def download_corpus(self, target_name, corpus_dir): |
| """Downloads the latest OSS-Fuzz corpus for the target. |
| |
| Returns: |
| The local path to to corpus or None if download failed. |
| """ |
| _make_empty_dir_if_nonexistent(corpus_dir) |
| project_qualified_fuzz_target_name = target_name |
| qualified_name_prefix = self.config.oss_fuzz_project_name + '_' |
| if not target_name.startswith(qualified_name_prefix): |
| project_qualified_fuzz_target_name = qualified_name_prefix + target_name |
| |
| corpus_url = (f'{utils.GCS_BASE_URL}{self.config.oss_fuzz_project_name}' |
| '-backup.clusterfuzz-external.appspot.com/corpus/' |
| f'libFuzzer/{project_qualified_fuzz_target_name}/' |
| f'{self.CORPUS_ZIP_NAME}') |
| |
| if not http_utils.download_and_unpack_zip(corpus_url, corpus_dir): |
| logging.warning('Failed to download corpus for %s.', target_name) |
| return corpus_dir |
| |
| def upload_coverage(self): |
| """Noop Implementation of upload_coverage_report.""" |
| logging.info('Not uploading coverage report because on OSS-Fuzz.') |
| |
| def get_coverage(self, repo_path): |
| """Returns the project coverage object for the project.""" |
| try: |
| return get_coverage.OSSFuzzCoverage(repo_path, |
| self.config.oss_fuzz_project_name) |
| except get_coverage.CoverageError: |
| return None |
| |
| |
| class NoClusterFuzzDeployment(BaseClusterFuzzDeployment): |
| """ClusterFuzzDeployment implementation used when there is no deployment of |
| ClusterFuzz to use.""" |
| |
| def upload_build(self, commit): # pylint: disable=no-self-use |
| """Noop Implementation of upload_build.""" |
| logging.info('Not uploading latest build because no ClusterFuzz ' |
| 'deployment.') |
| |
| def upload_corpus(self, target_name, corpus_dir, replace=False): # pylint: disable=no-self-use,unused-argument |
| """Noop Implementation of upload_corpus.""" |
| logging.info('Not uploading corpus because no ClusterFuzz deployment.') |
| |
| def upload_crashes(self): # pylint: disable=no-self-use |
| """Noop Implementation of upload_crashes.""" |
| logging.info('Not uploading crashes because no ClusterFuzz deployment.') |
| |
| def download_corpus(self, target_name, corpus_dir): |
| """Noop Implementation of download_corpus.""" |
| logging.info('Not downloading corpus because no ClusterFuzz deployment.') |
| return _make_empty_dir_if_nonexistent(corpus_dir) |
| |
| def download_latest_build(self): # pylint: disable=no-self-use |
| """Noop Implementation of download_latest_build.""" |
| logging.info( |
| 'Not downloading latest build because no ClusterFuzz deployment.') |
| |
| def upload_coverage(self): |
| """Noop Implementation of upload_coverage.""" |
| logging.info( |
| 'Not uploading coverage report because no ClusterFuzz deployment.') |
| |
| def get_coverage(self, repo_path): |
| """Noop Implementation of get_coverage.""" |
| logging.info( |
| 'Not getting project coverage because no ClusterFuzz deployment.') |
| |
| |
| _PLATFORM_CLUSTERFUZZ_DEPLOYMENT_MAPPING = { |
| config_utils.BaseConfig.Platform.INTERNAL_GENERIC_CI: |
| OSSFuzz, |
| config_utils.BaseConfig.Platform.INTERNAL_GITHUB: |
| OSSFuzz, |
| config_utils.BaseConfig.Platform.EXTERNAL_GENERIC_CI: |
| NoClusterFuzzDeployment, |
| config_utils.BaseConfig.Platform.EXTERNAL_GITHUB: |
| ClusterFuzzLite, |
| } |
| |
| |
| def get_clusterfuzz_deployment(config, workspace): |
| """Returns object reprsenting deployment of ClusterFuzz used by |config|.""" |
| deployment_cls = _PLATFORM_CLUSTERFUZZ_DEPLOYMENT_MAPPING[config.platform] |
| result = deployment_cls(config, workspace) |
| logging.info('ClusterFuzzDeployment: %s.', result) |
| return result |