1# Copyright 2020 Google Inc.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14#
15################################################################################
16#!/usr/bin/python2
17"""Starts and runs coverage build on Google Cloud Builder.
18Usage: build_and_run_coverage.py <project_dir>
19"""
20import datetime
21import json
22import logging
23import os
24import sys
25
26import build_lib
27import build_project
28
29SANITIZER = 'coverage'
30CONFIGURATION = ['FUZZING_ENGINE=libfuzzer', 'SANITIZER=%s' % SANITIZER]
31PLATFORM = 'linux'
32
33COVERAGE_BUILD_TAG = 'coverage'
34
35# Where code coverage reports need to be uploaded to.
36COVERAGE_BUCKET_NAME = 'oss-fuzz-coverage'
37
38# Link to the code coverage report in HTML format.
39HTML_REPORT_URL_FORMAT = (build_lib.GCS_URL_BASENAME + COVERAGE_BUCKET_NAME +
40                          '/{project}/reports/{date}/{platform}/index.html')
41
42# This is needed for ClusterFuzz to pick up the most recent reports data.
43LATEST_REPORT_INFO_URL = ('/' + COVERAGE_BUCKET_NAME +
44                          '/latest_report_info/{project}.json')
45LATEST_REPORT_INFO_CONTENT_TYPE = 'application/json'
46
47# Link where to upload code coverage report files to.
48UPLOAD_URL_FORMAT = 'gs://' + COVERAGE_BUCKET_NAME + '/{project}/{type}/{date}'
49
50# Languages from project.yaml that have code coverage support.
51LANGUAGES_WITH_COVERAGE_SUPPORT = ['c', 'c++', 'go', 'rust']
52
53
54def usage():
55  """Exit with code 1 and display syntax to use this file."""
56  sys.stderr.write("Usage: " + sys.argv[0] + " <project_dir>\n")
57  sys.exit(1)
58
59
60# pylint: disable=too-many-locals
61def get_build_steps(project_name, project_yaml_file, dockerfile_lines,
62                    image_project, base_images_project):
63  """Returns build steps for project."""
64  project_yaml = build_project.load_project_yaml(project_name,
65                                                 project_yaml_file,
66                                                 image_project)
67  if project_yaml['disabled']:
68    logging.info('Project "%s" is disabled.', project_name)
69    return []
70
71  if project_yaml['language'] not in LANGUAGES_WITH_COVERAGE_SUPPORT:
72    logging.info(
73        'Project "%s" is written in "%s", coverage is not supported yet.',
74        project_name, project_yaml['language'])
75    return []
76
77  name = project_yaml['name']
78  image = project_yaml['image']
79  language = project_yaml['language']
80  report_date = datetime.datetime.now().strftime('%Y%m%d')
81
82  build_steps = build_lib.project_image_steps(name, image, language)
83
84  env = CONFIGURATION[:]
85  out = '/workspace/out/' + SANITIZER
86  env.append('OUT=' + out)
87  env.append('FUZZING_LANGUAGE=' + language)
88
89  workdir = build_project.workdir_from_dockerfile(dockerfile_lines)
90  if not workdir:
91    workdir = '/src'
92
93  failure_msg = ('*' * 80 + '\nCoverage build failed.\nTo reproduce, run:\n'
94                 'python infra/helper.py build_image {name}\n'
95                 'python infra/helper.py build_fuzzers --sanitizer coverage '
96                 '{name}\n' + '*' * 80).format(name=name)
97
98  # Compilation step.
99  build_steps.append({
100      'name':
101          image,
102      'env':
103          env,
104      'args': [
105          'bash',
106          '-c',
107          # Remove /out to make sure there are non instrumented binaries.
108          # `cd /src && cd {workdir}` (where {workdir} is parsed from the
109          # Dockerfile). Container Builder overrides our workdir so we need
110          # to add this step to set it back.
111          ('rm -r /out && cd /src && cd {workdir} && mkdir -p {out} && '
112           'compile || (echo "{failure_msg}" && false)'
113          ).format(workdir=workdir, out=out, failure_msg=failure_msg),
114      ],
115  })
116
117  download_corpora_steps = build_lib.download_corpora_steps(project_name)
118  if not download_corpora_steps:
119    logging.info('Skipping code coverage build for %s.', project_name)
120    return []
121
122  build_steps.extend(download_corpora_steps)
123
124  failure_msg = ('*' * 80 + '\nCode coverage report generation failed.\n'
125                 'To reproduce, run:\n'
126                 'python infra/helper.py build_image {name}\n'
127                 'python infra/helper.py build_fuzzers --sanitizer coverage '
128                 '{name}\n'
129                 'python infra/helper.py coverage {name}\n' +
130                 '*' * 80).format(name=name)
131
132  # Unpack the corpus and run coverage script.
133  coverage_env = env + [
134      'HTTP_PORT=',
135      'COVERAGE_EXTRA_ARGS=%s' % project_yaml['coverage_extra_args'].strip(),
136  ]
137  if 'dataflow' in project_yaml['fuzzing_engines']:
138    coverage_env.append('FULL_SUMMARY_PER_TARGET=1')
139
140  build_steps.append({
141      'name': 'gcr.io/{0}/base-runner'.format(base_images_project),
142      'env': coverage_env,
143      'args': [
144          'bash', '-c',
145          ('for f in /corpus/*.zip; do unzip -q $f -d ${f%%.*} || ('
146           'echo "Failed to unpack the corpus for $(basename ${f%%.*}). '
147           'This usually means that corpus backup for a particular fuzz '
148           'target does not exist. If a fuzz target was added in the last '
149           '24 hours, please wait one more day. Otherwise, something is '
150           'wrong with the fuzz target or the infrastructure, and corpus '
151           'pruning task does not finish successfully." && exit 1'
152           '); done && coverage || (echo "' + failure_msg + '" && false)')
153      ],
154      'volumes': [{
155          'name': 'corpus',
156          'path': '/corpus'
157      }],
158  })
159
160  # Upload the report.
161  upload_report_url = UPLOAD_URL_FORMAT.format(project=project_name,
162                                               type='reports',
163                                               date=report_date)
164
165  # Delete the existing report as gsutil cannot overwrite it in a useful way due
166  # to the lack of `-T` option (it creates a subdir in the destination dir).
167  build_steps.append(build_lib.gsutil_rm_rf_step(upload_report_url))
168  build_steps.append({
169      'name':
170          'gcr.io/cloud-builders/gsutil',
171      'args': [
172          '-m',
173          'cp',
174          '-r',
175          os.path.join(out, 'report'),
176          upload_report_url,
177      ],
178  })
179
180  # Upload the fuzzer stats. Delete the old ones just in case.
181  upload_fuzzer_stats_url = UPLOAD_URL_FORMAT.format(project=project_name,
182                                                     type='fuzzer_stats',
183                                                     date=report_date)
184  build_steps.append(build_lib.gsutil_rm_rf_step(upload_fuzzer_stats_url))
185  build_steps.append({
186      'name':
187          'gcr.io/cloud-builders/gsutil',
188      'args': [
189          '-m',
190          'cp',
191          '-r',
192          os.path.join(out, 'fuzzer_stats'),
193          upload_fuzzer_stats_url,
194      ],
195  })
196
197  # Upload the fuzzer logs. Delete the old ones just in case
198  upload_fuzzer_logs_url = UPLOAD_URL_FORMAT.format(project=project_name,
199                                                    type='logs',
200                                                    date=report_date)
201  build_steps.append(build_lib.gsutil_rm_rf_step(upload_fuzzer_logs_url))
202  build_steps.append({
203      'name':
204          'gcr.io/cloud-builders/gsutil',
205      'args': [
206          '-m',
207          'cp',
208          '-r',
209          os.path.join(out, 'logs'),
210          upload_fuzzer_logs_url,
211      ],
212  })
213
214  # Upload srcmap.
215  srcmap_upload_url = UPLOAD_URL_FORMAT.format(project=project_name,
216                                               type='srcmap',
217                                               date=report_date)
218  srcmap_upload_url = srcmap_upload_url.rstrip('/') + '.json'
219  build_steps.append({
220      'name': 'gcr.io/cloud-builders/gsutil',
221      'args': [
222          'cp',
223          '/workspace/srcmap.json',
224          srcmap_upload_url,
225      ],
226  })
227
228  # Update the latest report information file for ClusterFuzz.
229  latest_report_info_url = build_lib.get_signed_url(
230      LATEST_REPORT_INFO_URL.format(project=project_name),
231      content_type=LATEST_REPORT_INFO_CONTENT_TYPE)
232  latest_report_info_body = json.dumps({
233      'fuzzer_stats_dir':
234          upload_fuzzer_stats_url,
235      'html_report_url':
236          HTML_REPORT_URL_FORMAT.format(project=project_name,
237                                        date=report_date,
238                                        platform=PLATFORM),
239      'report_date':
240          report_date,
241      'report_summary_path':
242          os.path.join(upload_report_url, PLATFORM, 'summary.json'),
243  })
244
245  build_steps.append(
246      build_lib.http_upload_step(latest_report_info_body,
247                                 latest_report_info_url,
248                                 LATEST_REPORT_INFO_CONTENT_TYPE))
249  return build_steps
250
251
252def main():
253  """Build and run coverage for projects."""
254  if len(sys.argv) != 2:
255    usage()
256
257  image_project = 'oss-fuzz'
258  base_images_project = 'oss-fuzz-base'
259  project_dir = sys.argv[1].rstrip(os.path.sep)
260  project_name = os.path.basename(project_dir)
261  dockerfile_path = os.path.join(project_dir, 'Dockerfile')
262  project_yaml_path = os.path.join(project_dir, 'project.yaml')
263
264  with open(dockerfile_path) as docker_file:
265    dockerfile_lines = docker_file.readlines()
266
267  with open(project_yaml_path) as project_yaml_file:
268    steps = get_build_steps(project_name, project_yaml_file, dockerfile_lines,
269                            image_project, base_images_project)
270
271  build_project.run_build(steps, project_name, COVERAGE_BUILD_TAG)
272
273
274if __name__ == "__main__":
275  main()
276