| #!/usr/bin/env python3 |
| # -*- coding: utf-8 -*- |
| # |
| # Copyright (C) 2017-2020 The Project X-Ray Authors. |
| # |
| # Use of this source code is governed by a ISC-style |
| # license that can be found in the LICENSE file or at |
| # https://opensource.org/licenses/ISC |
| # |
| # SPDX-License-Identifier: ISC |
| |
| from datetime import datetime, timedelta |
| |
| import argparse |
| import collections |
| import io |
| import os |
| import os.path |
| import re |
| import resource |
| import signal |
| import subprocess |
| import sys |
| import traceback |
| from utils.create_environment import get_environment_variables |
| |
| import junit_xml |
| |
| secs_in_min = 60 |
| secs_in_hour = 60 * secs_in_min |
| |
| |
| def pretty_timedelta_str(d): |
| """Pretty print a time delta object. |
| |
| >>> pretty_timedelta_str(timedelta(seconds=2)) |
| '2s' |
| |
| >>> pretty_timedelta_str(timedelta(seconds=61)) |
| '1m01s' |
| |
| >>> pretty_timedelta_str(timedelta(seconds=125)) |
| '2m05s' |
| |
| >>> pretty_timedelta_str(timedelta(seconds=secs_in_hour * 5)) |
| '5h00m' |
| |
| Once we get to displaying hours, we don't bother displaying seconds. |
| >>> pretty_timedelta_str(timedelta(seconds=secs_in_hour * 5 + 1)) |
| '5h00m' |
| |
| >>> pretty_timedelta_str(timedelta( |
| ... seconds=secs_in_hour * 5 + secs_in_min * 2 + 1)) |
| '5h02m' |
| |
| >>> pretty_timedelta_str(timedelta(seconds=secs_in_hour * 5.5)) |
| '5h30m' |
| """ |
| assert isinstance(d, timedelta) |
| ts = d.total_seconds() |
| assert ts > 0, (d, ts) |
| |
| bits = [] |
| hours = int(ts / secs_in_hour) |
| if hours > 0: |
| bits.append("{}h".format(hours)) |
| ts -= hours * secs_in_hour |
| |
| mins = int(ts / secs_in_min) |
| if mins > 0 or hours > 0: |
| if hours > 0: |
| bits.append("{:02d}m".format(mins)) |
| else: |
| bits.append("{:d}m".format(mins)) |
| ts -= mins * secs_in_min |
| |
| if hours == 0: |
| if mins > 0: |
| bits.append("{:02d}s".format(int(ts))) |
| else: |
| bits.append("{:d}s".format(int(ts))) |
| |
| assert len(bits) > 0, d |
| |
| return "".join(bits) |
| |
| |
| class Logger: |
| """Output a bunch of lines with a prefix. |
| |
| >>> l = Logger("fuzz", datetime(2001, 11, 1), 6) |
| >>> l._now = lambda: datetime(2001, 11, 1, second=10) |
| >>> l.log("Test!") |
| 2001-11-01T00:00:10 - fuzz - 10s: Test! |
| >>> l.log("Format {} {t}", [1,], {'t': 2}) |
| 2001-11-01T00:00:10 - fuzz - 10s: Format 1 2 |
| >>> l.log('''\\ |
| ... Line 1 |
| ... Line 2 |
| ... Line 3 |
| ... ''') |
| 2001-11-01T00:00:10 - fuzz - 10s: Line 1 |
| 2001-11-01T00:00:10 - fuzz - 10s: Line 2 |
| 2001-11-01T00:00:10 - fuzz - 10s: Line 3 |
| """ |
| |
| def __init__(self, fuzzer, time_start, padding): |
| self.fuzzer = fuzzer + ' ' * (padding - len(fuzzer)) |
| self.time_start = time_start |
| self.queue = collections.deque() |
| |
| def _now(self): |
| return datetime.utcnow() |
| |
| def log(self, msg, args=None, kw=None, flush=True): |
| """Queue a log message. |
| |
| This is safe to do in a signal handler where you can't do io. |
| """ |
| if args is None: |
| args = [] |
| if kw is None: |
| kw = {} |
| |
| time_log = self._now() |
| self.queue.append((time_log, msg, args, kw)) |
| if flush: |
| self.flush() |
| |
| def flush(self): |
| while len(self.queue) > 0: |
| time_log, msg, args, kw = self.queue.popleft() |
| self._output(time_log, msg, args, kw) |
| |
| def _output(self, time_log, msg, args, kw): |
| running_for = time_log - self.time_start |
| msg = msg.format(*args, **kw) |
| |
| time_log = time_log.replace(microsecond=0) |
| |
| log_prefix = "{:s} - {}/{:s} - {:>5s}: ".format( |
| time_log.isoformat(), |
| os.environ['XRAY_PART'], |
| self.fuzzer, |
| pretty_timedelta_str(running_for), |
| ) |
| |
| msg = "\n".join(log_prefix + x for x in msg.splitlines()) |
| print(msg, flush=True) |
| |
| |
| def last_lines(f, num, blocksize=100): |
| r"""Return n last lines in a file. |
| |
| >>> f = io.StringIO("") |
| >>> last_lines(f, 100) |
| [''] |
| |
| >>> f = io.StringIO("1\n2") |
| >>> last_lines(f, 100) |
| ['1', '2'] |
| |
| >>> f = io.StringIO("11\n22\n33\n44\n55") |
| >>> last_lines(f, 100) |
| ['11', '22', '33', '44', '55'] |
| |
| >>> f = io.StringIO("11\n22\n33\n44\n55") |
| >>> last_lines(f, 100, 2) |
| ['11', '22', '33', '44', '55'] |
| |
| >>> f = io.StringIO("11\n22\n33\n44\n55") |
| >>> last_lines(f, 100, 3) |
| ['11', '22', '33', '44', '55'] |
| |
| >>> f = io.StringIO("11\n22\n33\n44\n55") |
| >>> last_lines(f, 2, 2) |
| ['44', '55'] |
| |
| >>> f = io.StringIO("11\n22\n33\n44\n55") |
| >>> last_lines(f, 2, 3) |
| ['44', '55'] |
| |
| >>> f = io.StringIO("11\n22\n33\n44\n55") |
| >>> last_lines(f, 2, 4) |
| ['44', '55'] |
| |
| >>> f = io.StringIO("1\n22\n3333333\n") |
| >>> last_lines(f, 100, 3) |
| ['1', '22', '3333333', ''] |
| |
| """ |
| lines = collections.deque(['']) |
| # Seek to the end of the file |
| f.seek(0, os.SEEK_END) |
| fpos = f.tell() |
| |
| while len(lines) < (num + 1) and fpos > 0: |
| bs = min(blocksize, fpos) |
| fpos = fpos - bs |
| f.seek(fpos) |
| data = f.read(bs) |
| |
| while True: |
| lpos = data.rfind('\n') |
| lines[0] = data[lpos + 1:] + lines[0] |
| if lpos == -1: |
| break |
| lines.insert(0, '') |
| data = data[:lpos] |
| |
| if fpos == 0: |
| lines.insert(0, '') |
| |
| return list(lines)[1:] |
| |
| |
| def get_usage(): |
| # This function only works if you have a signal handler for the |
| # signal.SIGCHLD signal. |
| raw_usage = resource.getrusage(resource.RUSAGE_CHILDREN) |
| # 0 ru_utime time in user mode (float) |
| # 1 ru_stime time in system mode (float) |
| # 2 ru_maxrss maximum resident set size |
| # |
| # These fields are always zero on Linux |
| # 3 ru_ixrss shared memory size |
| # 4 ru_idrss unshared memory size |
| # 5 ru_isrss unshared stack size |
| return "User:{}s System:{}s".format( |
| int(raw_usage.ru_utime), |
| int(raw_usage.ru_stime), |
| ) |
| |
| |
| def get_load(): |
| """Return current load average. |
| |
| Values is average perecntage of CPU used over 1 minute, 5 minutes, 15 |
| minutes. |
| """ |
| a1, a5, a15 = os.getloadavg() |
| cpus = os.cpu_count() |
| return a1 / cpus * 100.0, a5 / cpus * 100.0, a15 / cpus * 100.0 |
| |
| |
| class PsTree: |
| _pstree_features = None |
| |
| @classmethod |
| def get_features(cls): |
| if cls._pstree_features is None: |
| cls._pstree_features = [] |
| for f in ['-T', '-l']: |
| try: |
| subprocess.check_output( |
| "pstree {}".format(f), |
| stderr=subprocess.STDOUT, |
| shell=True, |
| ) |
| cls._pstree_features.append(f) |
| except subprocess.CalledProcessError: |
| continue |
| return cls._pstree_features |
| |
| @classmethod |
| def get(cls, pid): |
| """Get processes under current one. |
| |
| Requires the pstree until be installed, otherwise returns empty string. |
| """ |
| p = subprocess.Popen( |
| "pstree {} {}".format(" ".join(cls.get_features()), pid), |
| shell=True, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| stdin=subprocess.DEVNULL, |
| universal_newlines=True, |
| ) |
| stdout, stderr = p.communicate() |
| |
| # Special case segfaults. |
| if 'Segmentation fault' in stderr: |
| stderr = '' |
| stdout = '(result too big, pstree segfaulted.)' |
| |
| # If no error, just return stdout |
| if not stderr: |
| return stdout |
| |
| return "{}\n{}".format(stdout, stderr) |
| |
| |
| def mem_convert(s): |
| """ |
| >>> mem_convert('62G') |
| 62000000000.0 |
| >>> mem_convert('62M') |
| 62000000.0 |
| >>> mem_convert('62B') |
| 62.0 |
| """ |
| units = { |
| 'Gi': pow(2, 30), |
| 'Mi': pow(2, 20), |
| 'Ki': pow(2, 10), |
| 'G': 1e9, |
| 'M': 1e6, |
| 'K': 1e3, |
| 'B': 1, |
| } |
| u = '', |
| m = 1 |
| for u, m in units.items(): |
| if s.endswith(u): |
| break |
| |
| v = float(s[:-len(u)]) |
| v = v * m |
| return v |
| |
| |
| def get_memory(memstr=None): |
| r""" |
| >>> import pprint |
| >>> pprint.pprint(get_memory('''\ |
| ... total used free shared buff/cache available |
| ... Mem: 62G 19G 4.8G 661M 38G 42G |
| ... Swap: 0B 0B 0B |
| ... ''')) |
| {'mem': {'available': 42000000000.0, |
| 'buff/cache': 38000000000.0, |
| 'free': 4800000000.0, |
| 'shared': 661000000.0, |
| 'total': 62000000000.0, |
| 'used': 19000000000.0}, |
| 'swap': {'free': 0.0, 'total': 0.0, 'used': 0.0}} |
| """ |
| if memstr is None: |
| memstr = subprocess.check_output( |
| 'free -mh', shell=True).decode("utf-8") |
| |
| lines = [x.split() for x in memstr.strip().splitlines()] |
| lines[0].insert(0, 'type:') |
| |
| for l in lines: |
| l[0] = l[0][:-1].lower() |
| |
| headers = lines[0][1:] |
| lines = lines[1:] |
| |
| memory = {} |
| for l in lines: |
| t, l = l[0], l[1:] |
| |
| d = {} |
| for k, v in zip(headers, l): |
| d[k] = mem_convert(v) |
| memory[t] = d |
| |
| return memory |
| |
| |
| def should_run_submake(make_flags): |
| """Check if make_flags indicate that we should execute things. |
| |
| See https://www.gnu.org/software/make/manual/html_node/Instead-of-Execution.html#Instead-of-Execution # noqa |
| |
| If this is a dry run or question then we shouldn't execute or output |
| anything. |
| |
| The flags end up as single letter versions in the MAKEFLAGS environment |
| variable. |
| |
| >>> should_run_submake('') |
| True |
| |
| The following flags are important; |
| |
| -n == --dry-run |
| |
| >>> should_run_submake('n') |
| False |
| >>> should_run_submake('n --blah') |
| False |
| >>> should_run_submake('--blah n') |
| False |
| >>> should_run_submake('--blah') |
| True |
| >>> should_run_submake('--random') |
| True |
| |
| -q == --question |
| |
| >>> should_run_submake('q') |
| False |
| >>> should_run_submake('q --blah') |
| False |
| >>> should_run_submake('--blah q') |
| False |
| >>> should_run_submake('--blah') |
| True |
| >>> should_run_submake('--random') |
| True |
| |
| Both --dry-run and --question |
| |
| >>> should_run_submake('qn') |
| False |
| >>> should_run_submake('nq') |
| False |
| >>> should_run_submake('--quiant') |
| True |
| """ |
| r = re.search(r'(?:^|\s)[^-]*(n|q)[^\s]*(\s|$)', make_flags) |
| if not r: |
| return True |
| return not bool(r.groups()[0]) |
| |
| |
| def main(argv): |
| fuzzers_dir = os.path.abspath(os.path.dirname(__file__)) |
| |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("fuzzer", help="fuzzer to run") |
| parser.add_argument( |
| "--retries", |
| type=int, |
| default=0, |
| help="Retry a failed fuzzer n times.", |
| ) |
| args = parser.parse_args() |
| |
| # Setup the logger with flush=False, it should be safe to use in a signal |
| # handler. |
| fuzzer_length = max(len(f) for f in os.listdir(fuzzers_dir)) |
| logger = Logger(args.fuzzer, datetime.utcnow(), fuzzer_length) |
| |
| # Need a signal handler on SIGCHLD otherwise get_resource doesn't return |
| # anything. |
| signal.signal(signal.SIGCHLD, lambda sig, frame: None) |
| |
| fuzzer_dir = os.path.join(fuzzers_dir, args.fuzzer) |
| assert os.path.exists(fuzzer_dir), fuzzer_dir |
| |
| fuzzer_logdir = os.path.join( |
| fuzzer_dir, "logs_{}".format(os.environ['XRAY_PART'])) |
| if not os.path.exists(fuzzer_logdir): |
| os.makedirs(fuzzer_logdir) |
| assert os.path.exists(fuzzer_logdir) |
| |
| # Update the environment for a specific part |
| environment = get_environment_variables() |
| for key, value in environment.items(): |
| os.environ[key] = value |
| |
| exit_code = -1 |
| args.retries += 1 |
| for retry_count in range(0, args.retries): |
| logger.log('Running fuzzer attempt: {}', [retry_count]) |
| exit_code = run_fuzzer( |
| args.fuzzer, |
| fuzzer_dir, |
| fuzzer_logdir, |
| logger, |
| will_retry=retry_count < (args.retries - 1), |
| ) |
| if exit_code <= 0: |
| break |
| logger.log('WARNING: Fuzzer failed!') |
| |
| return exit_code |
| |
| |
| def run_fuzzer(fuzzer_name, fuzzer_dir, fuzzer_logdir, logger, will_retry): |
| def log(msg, *a, **k): |
| logger.log(msg, a, k, flush=True) |
| |
| make_cmd = os.environ.get('MAKE', 'make') |
| make_flags = os.environ.get('MAKEFLAGS', '') |
| # Should run things? |
| if not should_run_submake(make_flags): |
| return 0 |
| |
| fuzzer_runok = os.path.join( |
| fuzzer_dir, "run.{}.ok".format(os.environ['XRAY_PART'])) |
| if os.path.exists(fuzzer_runok): |
| last_modified = datetime.fromtimestamp(os.stat(fuzzer_runok).st_mtime) |
| |
| log( |
| "Skipping as run.{}.ok exists (updated @ {})", |
| os.environ['XRAY_PART'], last_modified.isoformat()) |
| |
| return 0 |
| |
| fuzzer_runok = os.path.join(fuzzer_dir, "run.ok") |
| if os.path.exists(fuzzer_runok): |
| last_modified = datetime.fromtimestamp(os.stat(fuzzer_runok).st_mtime) |
| |
| log( |
| "Skipping as run.ok exists (updated @ {})", |
| last_modified.isoformat()) |
| |
| return 0 |
| |
| time_start = datetime.utcnow() |
| log("Starting @ {}", time_start.isoformat()) |
| |
| running_msg = "Running {} -C {} run (with MAKEFLAGS='{}')".format( |
| make_cmd, |
| fuzzer_dir, |
| make_flags, |
| ) |
| log(running_msg) |
| |
| log_suffix = ".{}.log".format(time_start.isoformat()).replace(":", "-") |
| fuzzer_stdout = os.path.join(fuzzer_logdir, "stdout" + log_suffix) |
| fuzzer_stderr = os.path.join(fuzzer_logdir, "stderr" + log_suffix) |
| |
| # Write header to stdout/stderr to make sure they match. |
| for fname in [fuzzer_stdout, fuzzer_stderr]: |
| with open(fname, "w") as fd: |
| fd.write("Build starting @ {}\n".format(time_start.isoformat())) |
| fd.write(running_msg) |
| fd.write("\n") |
| fd.write("-" * 75) |
| fd.write("\n") |
| fd.flush() |
| os.fsync(fd) |
| |
| # Open the log files for appending |
| stdout_fd = open(fuzzer_stdout, "a") |
| stderr_fd = open(fuzzer_stderr, "a") |
| |
| # Play nice with make's jobserver. |
| # See https://www.gnu.org/software/make/manual/html_node/POSIX-Jobserver.html#POSIX-Jobserver # noqa |
| job_fds = [] |
| if '--jobserver-fds' in make_flags or '--jobserver-auth' in make_flags: |
| job_re = re.search( |
| '--jobserver-(?:fds|auth)=([0-9]+),([0-9]+)', make_flags) |
| assert job_re, make_flags |
| job_rd, job_wr = job_re.groups() |
| |
| # Make copies of jobserver FDs in case a retry is needed. |
| |
| job_rd = int(job_rd) |
| job_wr = int(job_wr) |
| assert job_rd > 2, (job_rd, job_wr, make_flags) |
| assert job_wr > 2, (job_rd, job_wr, make_flags) |
| job_fds.append(job_rd) |
| job_fds.append(job_wr) |
| |
| p = None |
| try: |
| p = subprocess.Popen( |
| [make_cmd, '-C', fuzzer_dir, 'run'], |
| stdin=None, |
| stdout=stdout_fd, |
| stderr=stderr_fd, |
| pass_fds=job_fds, |
| ) |
| while True: |
| try: |
| retcode = p.wait(timeout=10) |
| p = None |
| except subprocess.TimeoutExpired: |
| retcode = None |
| |
| if retcode is not None: |
| break |
| mem = get_memory()['mem'] |
| log( |
| "Still running (1m:{:0.2f}%, 5m:{:0.2f}%, 15m:{:0.2f}% Mem:{:0.1f}Gi used, {:0.1f}Gi free).\n{}", |
| *get_load(), |
| mem['used'] / 1e9, |
| mem['available'] / |
| 1e9, # Using available so the numbers add up. |
| PsTree.get(p.pid), |
| ) |
| except (Exception, KeyboardInterrupt, SystemExit): |
| retcode = -1 |
| tb = io.StringIO() |
| traceback.print_exc(file=tb) |
| log(tb.getvalue()) |
| |
| # Prevent Ctrl-C so we exit properly... |
| old_sigint_handler = signal.getsignal(signal.SIGINT) |
| |
| def log_die(sig, frame): |
| logger.log("Dieing!") |
| |
| signal.signal(signal.SIGINT, log_die) |
| |
| # Cleanup child process if they haven't already died. |
| try: |
| if p is not None: |
| try: |
| retcode = p.wait(1) |
| except subprocess.TimeoutExpired: |
| retcode = -1 |
| p.kill() |
| p.wait() |
| log("Warning: Killed program which should have been dead!") |
| except Exception: |
| tb = io.StringIO() |
| traceback.print_exc(file=tb) |
| log(tb.getvalue()) |
| |
| # Wait for all children to finish. |
| try: |
| while True: |
| log("Child finished: {}", os.waitpid(-1, 0)) |
| except ChildProcessError: |
| pass |
| |
| log("Finishing ({}).", get_usage()) |
| |
| time_end = datetime.utcnow() |
| |
| error_log = "\n".join(last_lines(open(fuzzer_stderr), 10000)) |
| success_log = "\n".join(last_lines(open(fuzzer_stdout), 100)) |
| |
| # Find the next X_sponge_log.xml file name... |
| for i in range(0, 100): |
| tsfilename = os.path.join(fuzzer_logdir, '{}_sponge_log.xml'.format(i)) |
| if not os.path.exists(tsfilename): |
| break |
| |
| test_case = junit_xml.TestCase( |
| name=fuzzer_name, |
| timestamp=time_start.timestamp(), |
| elapsed_sec=(time_end - time_start).total_seconds(), |
| stdout=success_log, |
| stderr=error_log, |
| ) |
| |
| if retcode != 0: |
| test_case.add_failure_info( |
| 'Fuzzer failed with exit code: {}'.format(retcode)) |
| |
| with open(tsfilename, 'w') as f: |
| ts = junit_xml.TestSuite(fuzzer_name, [test_case]) |
| junit_xml.TestSuite.to_file(f, [ts]) |
| |
| if retcode != 0: |
| test_case.add_failure_info( |
| 'Fuzzer failed with exit code: {}'.format(retcode), ) |
| |
| # Log the last 10,000 lines of stderr on a failure |
| log( |
| """\ |
| -------------------------------------------------------------------------- |
| !Failed! @ {time_end} with exit code: {retcode} |
| -------------------------------------------------------------------------- |
| - STDOUT (see {stdout_fname} for full log): |
| -------------------------------------------------------------------------- |
| {stdout_log} |
| -------------------------------------------------------------------------- |
| - STDERR (see {stderr_fname} for full log): |
| -------------------------------------------------------------------------- |
| {stderr_log} |
| -------------------------------------------------------------------------- |
| !Failed! @ {time_end} with exit code: {retcode} |
| -------------------------------------------------------------------------- |
| """, |
| retcode=retcode, |
| stdout_fname=fuzzer_stdout, |
| stdout_log='\n'.join(last_lines(open(fuzzer_stdout), 1000)), |
| stderr_fname=fuzzer_stderr, |
| stderr_log='\n'.join(last_lines(open(fuzzer_stderr), 1000)), |
| time_end=time_end.isoformat()) |
| else: |
| # Log the last 100 lines of a successful run |
| log( |
| """\ |
| Succeeded! @ {} |
| -------------------------------------------------------------------------- |
| {} |
| -------------------------------------------------------------------------- |
| Succeeded! @ {} |
| """, time_end.isoformat(), success_log, time_end.isoformat()) |
| |
| logger.flush() |
| signal.signal(signal.SIGINT, old_sigint_handler) |
| return retcode |
| |
| |
| if __name__ == "__main__": |
| if "--test" in sys.argv or len(sys.argv) == 1: |
| import doctest |
| doctest.testmod() |
| else: |
| sys.exit(main(sys.argv)) |