485 lines · python
1#!/usr/bin/env python32"""Calls reduction tools to create minimal reproducers for clang crashes.3 4Unknown arguments are treated at cvise/creduce options.5 6Output files:7 *.reduced.sh -- crash reproducer with minimal arguments8 *.reduced.cpp -- the reduced file9 *.test.sh -- interestingness test for C-Vise10"""11 12from argparse import ArgumentParser, RawTextHelpFormatter13import os14import re15import shutil16import stat17import sys18import subprocess19import shlex20import tempfile21import shutil22import multiprocessing23 24verbose = False25creduce_cmd = None26clang_cmd = None27 28 29def verbose_print(*args, **kwargs):30 if verbose:31 print(*args, **kwargs)32 33 34def check_file(fname):35 fname = os.path.normpath(fname)36 if not os.path.isfile(fname):37 sys.exit("ERROR: %s does not exist" % (fname))38 return fname39 40 41def check_cmd(cmd_name, cmd_dir, cmd_path=None):42 """43 Returns absolute path to cmd_path if it is given,44 or absolute path to cmd_dir/cmd_name.45 """46 if cmd_path:47 # Make the path absolute so the creduce test can be run from any directory.48 cmd_path = os.path.abspath(cmd_path)49 cmd = shutil.which(cmd_path)50 if cmd:51 return cmd52 sys.exit("ERROR: executable `%s` not found" % (cmd_path))53 54 cmd = shutil.which(cmd_name, path=cmd_dir)55 if cmd:56 return cmd57 58 if not cmd_dir:59 cmd_dir = "$PATH"60 sys.exit("ERROR: `%s` not found in %s" % (cmd_name, cmd_dir))61 62 63def quote_cmd(cmd):64 return " ".join(shlex.quote(arg) for arg in cmd)65 66 67def write_to_script(text, filename):68 with open(filename, "w") as f:69 f.write(text)70 os.chmod(filename, os.stat(filename).st_mode | stat.S_IEXEC)71 72 73class Reduce(object):74 def __init__(self, crash_script, file_to_reduce, creduce_flags):75 crash_script_name, crash_script_ext = os.path.splitext(crash_script)76 file_reduce_name, file_reduce_ext = os.path.splitext(file_to_reduce)77 78 self.testfile = file_reduce_name + ".test.sh"79 self.crash_script = crash_script_name + ".reduced" + crash_script_ext80 self.file_to_reduce = file_reduce_name + ".reduced" + file_reduce_ext81 shutil.copy(file_to_reduce, self.file_to_reduce)82 83 self.clang = clang_cmd84 self.clang_args = []85 self.expected_output = []86 self.needs_stack_trace = False87 self.creduce_flags = ["--tidy"] + creduce_flags88 89 self.read_clang_args(crash_script, file_to_reduce)90 self.read_expected_output()91 92 def get_crash_cmd(self, cmd=None, args=None, filename=None):93 if not cmd:94 cmd = self.clang95 if not args:96 args = self.clang_args97 if not filename:98 filename = self.file_to_reduce99 100 return [cmd] + args + [filename]101 102 def read_clang_args(self, crash_script, filename):103 print("\nReading arguments from crash script...")104 with open(crash_script) as f:105 # Assume clang call is the first non comment line.106 cmd = []107 for line in f:108 if not line.lstrip().startswith("#"):109 cmd = shlex.split(line)110 break111 if not cmd:112 sys.exit("Could not find command in the crash script.")113 114 # Remove clang and filename from the command115 # Assume the last occurrence of the filename is the clang input file116 del cmd[0]117 for i in range(len(cmd) - 1, -1, -1):118 if cmd[i] == filename:119 del cmd[i]120 break121 self.clang_args = cmd122 verbose_print("Clang arguments:", quote_cmd(self.clang_args))123 124 def read_expected_output(self):125 print("\nGetting expected crash output...")126 p = subprocess.Popen(127 self.get_crash_cmd(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT128 )129 crash_output, _ = p.communicate()130 result = []131 132 # Remove color codes133 ansi_escape = r"\x1b\[[0-?]*m"134 crash_output = re.sub(ansi_escape, "", crash_output.decode("utf-8"))135 136 # Look for specific error messages137 regexes = [138 r"Assertion .+ failed", # Linux assert()139 r"Assertion failed: .+,", # FreeBSD/Mac assert()140 r"fatal error: error in backend: .+",141 r"LLVM ERROR: .+",142 r"UNREACHABLE executed at .+?!",143 r"LLVM IR generation of declaration '.+'",144 r"Generating code for declaration '.+'",145 r"\*\*\* Bad machine code: .+ \*\*\*",146 r"ERROR: .*Sanitizer: [^ ]+ ",147 ]148 for msg_re in regexes:149 match = re.search(msg_re, crash_output)150 if match:151 msg = match.group(0)152 result = [msg]153 print("Found message:", msg)154 break155 156 # If no message was found, use the top five stack trace functions,157 # ignoring some common functions158 # Five is a somewhat arbitrary number; the goal is to get a small number159 # of identifying functions with some leeway for common functions160 if not result:161 self.needs_stack_trace = True162 stacktrace_re = r"[0-9]+\s+0[xX][0-9a-fA-F]+\s*([^(]+)\("163 filters = [164 "PrintStackTrace",165 "RunSignalHandlers",166 "CleanupOnSignal",167 "HandleCrash",168 "SignalHandler",169 "__restore_rt",170 "gsignal",171 "abort",172 ]173 174 def skip_function(func_name):175 return any(name in func_name for name in filters)176 177 matches = re.findall(stacktrace_re, crash_output)178 result = [x for x in matches if x and not skip_function(x)][:5]179 for msg in result:180 print("Found stack trace function:", msg)181 182 if not result:183 print("ERROR: no crash was found")184 print("The crash output was:\n========\n%s========" % crash_output)185 sys.exit(1)186 187 self.expected_output = result188 189 def check_expected_output(self, args=None, filename=None):190 if not args:191 args = self.clang_args192 if not filename:193 filename = self.file_to_reduce194 195 p = subprocess.Popen(196 self.get_crash_cmd(args=args, filename=filename),197 stdout=subprocess.PIPE,198 stderr=subprocess.STDOUT,199 )200 crash_output, _ = p.communicate()201 return all(msg in crash_output.decode("utf-8") for msg in self.expected_output)202 203 def write_interestingness_test(self):204 print("\nCreating the interestingness test...")205 206 # Disable symbolization if it's not required to avoid slow symbolization.207 disable_symbolization = ""208 if not self.needs_stack_trace:209 disable_symbolization = "export LLVM_DISABLE_SYMBOLIZATION=1"210 211 output = """#!/bin/bash212%s213if %s >& t.log ; then214 exit 1215fi216""" % (217 disable_symbolization,218 quote_cmd(self.get_crash_cmd()),219 )220 221 for msg in self.expected_output:222 output += "grep -F %s t.log || exit 1\n" % shlex.quote(msg)223 224 write_to_script(output, self.testfile)225 self.check_interestingness()226 227 def check_interestingness(self):228 testfile = os.path.abspath(self.testfile)229 230 # Check that the test considers the original file interesting231 returncode = subprocess.call(testfile, stdout=subprocess.DEVNULL)232 if returncode:233 sys.exit("The interestingness test does not pass for the original file.")234 235 # Check that an empty file is not interesting236 # Instead of modifying the filename in the test file, just run the command237 with tempfile.NamedTemporaryFile() as empty_file:238 is_interesting = self.check_expected_output(filename=empty_file.name)239 if is_interesting:240 sys.exit("The interestingness test passes for an empty file.")241 242 def clang_preprocess(self):243 print("\nTrying to preprocess the source file...")244 with tempfile.NamedTemporaryFile() as tmpfile:245 cmd_preprocess = self.get_crash_cmd() + ["-E", "-o", tmpfile.name]246 cmd_preprocess_no_lines = cmd_preprocess + ["-P"]247 try:248 subprocess.check_call(cmd_preprocess_no_lines)249 if self.check_expected_output(filename=tmpfile.name):250 print("Successfully preprocessed with line markers removed")251 shutil.copy(tmpfile.name, self.file_to_reduce)252 else:253 subprocess.check_call(cmd_preprocess)254 if self.check_expected_output(filename=tmpfile.name):255 print("Successfully preprocessed without removing line markers")256 shutil.copy(tmpfile.name, self.file_to_reduce)257 else:258 print(259 "No longer crashes after preprocessing -- "260 "using original source"261 )262 except subprocess.CalledProcessError:263 print("Preprocessing failed")264 265 @staticmethod266 def filter_args(267 args, opts_equal=[], opts_startswith=[], opts_one_arg_startswith=[]268 ):269 result = []270 skip_next = False271 for arg in args:272 if skip_next:273 skip_next = False274 continue275 if any(arg == a for a in opts_equal):276 continue277 if any(arg.startswith(a) for a in opts_startswith):278 continue279 if any(arg.startswith(a) for a in opts_one_arg_startswith):280 skip_next = True281 continue282 result.append(arg)283 return result284 285 def try_remove_args(self, args, msg=None, extra_arg=None, **kwargs):286 new_args = self.filter_args(args, **kwargs)287 288 if extra_arg:289 if extra_arg in new_args:290 new_args.remove(extra_arg)291 new_args.append(extra_arg)292 293 if new_args != args and self.check_expected_output(args=new_args):294 if msg:295 verbose_print(msg)296 return new_args297 return args298 299 def try_remove_arg_by_index(self, args, index):300 new_args = args[:index] + args[index + 1 :]301 removed_arg = args[index]302 303 # Heuristic for grouping arguments:304 # remove next argument if it doesn't start with "-"305 if index < len(new_args) and not new_args[index].startswith("-"):306 del new_args[index]307 removed_arg += " " + args[index + 1]308 309 if self.check_expected_output(args=new_args):310 verbose_print("Removed", removed_arg)311 return new_args, index312 return args, index + 1313 314 def simplify_clang_args(self):315 """Simplify clang arguments before running C-Vise to reduce the time the316 interestingness test takes to run.317 """318 print("\nSimplifying the clang command...")319 new_args = self.clang_args320 321 # Remove the color diagnostics flag to make it easier to match error322 # text.323 new_args = self.try_remove_args(324 new_args,325 msg="Removed -fcolor-diagnostics",326 opts_equal=["-fcolor-diagnostics"],327 )328 329 # Remove some clang arguments to speed up the interestingness test330 new_args = self.try_remove_args(331 new_args,332 msg="Removed debug info options",333 opts_startswith=["-gcodeview", "-debug-info-kind=", "-debugger-tuning="],334 )335 336 new_args = self.try_remove_args(337 new_args, msg="Removed --show-includes", opts_startswith=["--show-includes"]338 )339 # Not suppressing warnings (-w) sometimes prevents the crash from occurring340 # after preprocessing341 new_args = self.try_remove_args(342 new_args,343 msg="Replaced -W options with -w",344 extra_arg="-w",345 opts_startswith=["-W"],346 )347 new_args = self.try_remove_args(348 new_args,349 msg="Replaced optimization level with -O0",350 extra_arg="-O0",351 opts_startswith=["-O"],352 )353 354 # Try to remove compilation steps355 new_args = self.try_remove_args(356 new_args, msg="Added -emit-llvm", extra_arg="-emit-llvm"357 )358 new_args = self.try_remove_args(359 new_args, msg="Added -fsyntax-only", extra_arg="-fsyntax-only"360 )361 362 # Try to make implicit int an error for more sensible test output363 new_args = self.try_remove_args(364 new_args,365 msg="Added -Werror=implicit-int",366 opts_equal=["-w"],367 extra_arg="-Werror=implicit-int",368 )369 370 self.clang_args = new_args371 verbose_print("Simplified command:", quote_cmd(self.get_crash_cmd()))372 373 def reduce_clang_args(self):374 """Minimize the clang arguments after running C-Vise, to get the smallest375 command that reproduces the crash on the reduced file.376 """377 print("\nReducing the clang crash command...")378 379 new_args = self.clang_args380 381 # Remove some often occurring args382 new_args = self.try_remove_args(383 new_args, msg="Removed -D options", opts_startswith=["-D"]384 )385 new_args = self.try_remove_args(386 new_args, msg="Removed -D options", opts_one_arg_startswith=["-D"]387 )388 new_args = self.try_remove_args(389 new_args, msg="Removed -I options", opts_startswith=["-I"]390 )391 new_args = self.try_remove_args(392 new_args, msg="Removed -I options", opts_one_arg_startswith=["-I"]393 )394 new_args = self.try_remove_args(395 new_args, msg="Removed -W options", opts_startswith=["-W"]396 )397 398 # Remove other cases that aren't covered by the heuristic399 new_args = self.try_remove_args(400 new_args, msg="Removed -mllvm", opts_one_arg_startswith=["-mllvm"]401 )402 403 i = 0404 while i < len(new_args):405 new_args, i = self.try_remove_arg_by_index(new_args, i)406 407 self.clang_args = new_args408 409 reduced_cmd = quote_cmd(self.get_crash_cmd())410 write_to_script(reduced_cmd, self.crash_script)411 print("Reduced command:", reduced_cmd)412 413 def run_creduce(self):414 full_creduce_cmd = (415 [creduce_cmd] + self.creduce_flags + [self.testfile, self.file_to_reduce]416 )417 print("\nRunning C reduction tool...")418 verbose_print(quote_cmd(full_creduce_cmd))419 try:420 p = subprocess.Popen(full_creduce_cmd)421 p.communicate()422 except KeyboardInterrupt:423 # Hack to kill C-Reduce because it jumps into its own pgid424 print("\n\nctrl-c detected, killed reduction tool")425 p.kill()426 427 428def main():429 global verbose430 global creduce_cmd431 global clang_cmd432 433 parser = ArgumentParser(description=__doc__, formatter_class=RawTextHelpFormatter)434 parser.add_argument(435 "crash_script",436 type=str,437 nargs=1,438 help="Name of the script that generates the crash.",439 )440 parser.add_argument(441 "file_to_reduce", type=str, nargs=1, help="Name of the file to be reduced."442 )443 parser.add_argument(444 "--llvm-bin", dest="llvm_bin", type=str, help="Path to the LLVM bin directory."445 )446 parser.add_argument(447 "--clang",448 dest="clang",449 type=str,450 help="The path to the `clang` executable. "451 "By default uses the llvm-bin directory.",452 )453 parser.add_argument(454 "--creduce",455 dest="creduce",456 type=str,457 help="The path to the `creduce` or `cvise` executable. "458 "Required if neither `creduce` nor `cvise` are on PATH.",459 )460 parser.add_argument("-v", "--verbose", action="store_true")461 args, creduce_flags = parser.parse_known_args()462 verbose = args.verbose463 llvm_bin = os.path.abspath(args.llvm_bin) if args.llvm_bin else None464 creduce_cmd = check_cmd("creduce", None, args.creduce)465 creduce_cmd = check_cmd("cvise", None, args.creduce)466 clang_cmd = check_cmd("clang", llvm_bin, args.clang)467 468 crash_script = check_file(args.crash_script[0])469 file_to_reduce = check_file(args.file_to_reduce[0])470 471 if "--n" not in creduce_flags:472 creduce_flags += ["--n", str(max(4, multiprocessing.cpu_count() // 2))]473 474 r = Reduce(crash_script, file_to_reduce, creduce_flags)475 476 r.simplify_clang_args()477 r.write_interestingness_test()478 r.clang_preprocess()479 r.run_creduce()480 r.reduce_clang_args()481 482 483if __name__ == "__main__":484 main()485