2
0

runner.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. #!/usr/bin/env python3
  2. # Tool for running fuzz tests
  3. #
  4. # Copyright (C) 2014 Maria Kustova <maria.k@catit.be>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 2 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. #
  19. import sys
  20. import os
  21. import signal
  22. import subprocess
  23. import random
  24. import shutil
  25. from itertools import count
  26. import time
  27. import getopt
  28. import io
  29. import resource
  30. try:
  31. import json
  32. except ImportError:
  33. try:
  34. import simplejson as json
  35. except ImportError:
  36. print("Warning: Module for JSON processing is not found.\n" \
  37. "'--config' and '--command' options are not supported.", file=sys.stderr)
  38. # Backing file sizes in MB
  39. MAX_BACKING_FILE_SIZE = 10
  40. MIN_BACKING_FILE_SIZE = 1
  41. def multilog(msg, *output):
  42. """ Write an object to all of specified file descriptors."""
  43. for fd in output:
  44. fd.write(msg)
  45. fd.flush()
  46. def str_signal(sig):
  47. """ Convert a numeric value of a system signal to the string one
  48. defined by the current operational system.
  49. """
  50. for k, v in signal.__dict__.items():
  51. if v == sig:
  52. return k
  53. def run_app(fd, q_args):
  54. """Start an application with specified arguments and return its exit code
  55. or kill signal depending on the result of execution.
  56. """
  57. class Alarm(Exception):
  58. """Exception for signal.alarm events."""
  59. pass
  60. def handler(*args):
  61. """Notify that an alarm event occurred."""
  62. raise Alarm
  63. signal.signal(signal.SIGALRM, handler)
  64. signal.alarm(600)
  65. term_signal = signal.SIGKILL
  66. devnull = open('/dev/null', 'r+')
  67. process = subprocess.Popen(q_args, stdin=devnull,
  68. stdout=subprocess.PIPE,
  69. stderr=subprocess.PIPE,
  70. errors='replace')
  71. try:
  72. out, err = process.communicate()
  73. signal.alarm(0)
  74. fd.write(out)
  75. fd.write(err)
  76. fd.flush()
  77. return process.returncode
  78. except Alarm:
  79. os.kill(process.pid, term_signal)
  80. fd.write('The command was terminated by timeout.\n')
  81. fd.flush()
  82. return -term_signal
  83. class TestException(Exception):
  84. """Exception for errors risen by TestEnv objects."""
  85. pass
  86. class TestEnv(object):
  87. """Test object.
  88. The class sets up test environment, generates backing and test images
  89. and executes application under tests with specified arguments and a test
  90. image provided.
  91. All logs are collected.
  92. The summary log will contain short descriptions and statuses of tests in
  93. a run.
  94. The test log will include application (e.g. 'qemu-img') logs besides info
  95. sent to the summary log.
  96. """
  97. def __init__(self, test_id, seed, work_dir, run_log,
  98. cleanup=True, log_all=False):
  99. """Set test environment in a specified work directory.
  100. Path to qemu-img and qemu-io will be retrieved from 'QEMU_IMG' and
  101. 'QEMU_IO' environment variables.
  102. """
  103. if seed is not None:
  104. self.seed = seed
  105. else:
  106. self.seed = str(random.randint(0, sys.maxsize))
  107. random.seed(self.seed)
  108. self.init_path = os.getcwd()
  109. self.work_dir = work_dir
  110. self.current_dir = os.path.join(work_dir, 'test-' + test_id)
  111. self.qemu_img = \
  112. os.environ.get('QEMU_IMG', 'qemu-img').strip().split(' ')
  113. self.qemu_io = os.environ.get('QEMU_IO', 'qemu-io').strip().split(' ')
  114. self.commands = [['qemu-img', 'check', '-f', 'qcow2', '$test_img'],
  115. ['qemu-img', 'info', '-f', 'qcow2', '$test_img'],
  116. ['qemu-io', '$test_img', '-c', 'read $off $len'],
  117. ['qemu-io', '$test_img', '-c', 'write $off $len'],
  118. ['qemu-io', '$test_img', '-c',
  119. 'aio_read $off $len'],
  120. ['qemu-io', '$test_img', '-c',
  121. 'aio_write $off $len'],
  122. ['qemu-io', '$test_img', '-c', 'flush'],
  123. ['qemu-io', '$test_img', '-c',
  124. 'discard $off $len'],
  125. ['qemu-io', '$test_img', '-c',
  126. 'truncate $off']]
  127. for fmt in ['raw', 'vmdk', 'vdi', 'qcow2', 'file', 'qed', 'vpc']:
  128. self.commands.append(
  129. ['qemu-img', 'convert', '-f', 'qcow2', '-O', fmt,
  130. '$test_img', 'converted_image.' + fmt])
  131. try:
  132. os.makedirs(self.current_dir)
  133. except OSError as e:
  134. print("Error: The working directory '%s' cannot be used. Reason: %s"\
  135. % (self.work_dir, e.strerror), file=sys.stderr)
  136. raise TestException
  137. self.log = open(os.path.join(self.current_dir, "test.log"), "w")
  138. self.parent_log = open(run_log, "a")
  139. self.failed = False
  140. self.cleanup = cleanup
  141. self.log_all = log_all
  142. def _create_backing_file(self):
  143. """Create a backing file in the current directory.
  144. Return a tuple of a backing file name and format.
  145. Format of a backing file is randomly chosen from all formats supported
  146. by 'qemu-img create'.
  147. """
  148. # All formats supported by the 'qemu-img create' command.
  149. backing_file_fmt = random.choice(['raw', 'vmdk', 'vdi', 'qcow2',
  150. 'file', 'qed', 'vpc'])
  151. backing_file_name = 'backing_img.' + backing_file_fmt
  152. backing_file_size = random.randint(MIN_BACKING_FILE_SIZE,
  153. MAX_BACKING_FILE_SIZE) * (1 << 20)
  154. cmd = self.qemu_img + ['create', '-f', backing_file_fmt,
  155. backing_file_name, str(backing_file_size)]
  156. temp_log = io.StringIO()
  157. retcode = run_app(temp_log, cmd)
  158. if retcode == 0:
  159. temp_log.close()
  160. return (backing_file_name, backing_file_fmt)
  161. else:
  162. multilog("Warning: The %s backing file was not created.\n\n"
  163. % backing_file_fmt, sys.stderr, self.log, self.parent_log)
  164. self.log.write("Log for the failure:\n" + temp_log.getvalue() +
  165. '\n\n')
  166. temp_log.close()
  167. return (None, None)
  168. def execute(self, input_commands=None, fuzz_config=None):
  169. """ Execute a test.
  170. The method creates backing and test images, runs test app and analyzes
  171. its exit status. If the application was killed by a signal, the test
  172. is marked as failed.
  173. """
  174. if input_commands is None:
  175. commands = self.commands
  176. else:
  177. commands = input_commands
  178. os.chdir(self.current_dir)
  179. backing_file_name, backing_file_fmt = self._create_backing_file()
  180. img_size = image_generator.create_image(
  181. 'test.img', backing_file_name, backing_file_fmt, fuzz_config)
  182. for item in commands:
  183. shutil.copy('test.img', 'copy.img')
  184. # 'off' and 'len' are multiple of the sector size
  185. sector_size = 512
  186. start = random.randrange(0, img_size + 1, sector_size)
  187. end = random.randrange(start, img_size + 1, sector_size)
  188. if item[0] == 'qemu-img':
  189. current_cmd = list(self.qemu_img)
  190. elif item[0] == 'qemu-io':
  191. current_cmd = list(self.qemu_io)
  192. else:
  193. multilog("Warning: test command '%s' is not defined.\n"
  194. % item[0], sys.stderr, self.log, self.parent_log)
  195. continue
  196. # Replace all placeholders with their real values
  197. for v in item[1:]:
  198. c = (v
  199. .replace('$test_img', 'copy.img')
  200. .replace('$off', str(start))
  201. .replace('$len', str(end - start)))
  202. current_cmd.append(c)
  203. # Log string with the test header
  204. test_summary = "Seed: %s\nCommand: %s\nTest directory: %s\n" \
  205. "Backing file: %s\n" \
  206. % (self.seed, " ".join(current_cmd),
  207. self.current_dir, backing_file_name)
  208. temp_log = io.StringIO()
  209. try:
  210. retcode = run_app(temp_log, current_cmd)
  211. except OSError as e:
  212. multilog("%sError: Start of '%s' failed. Reason: %s\n\n"
  213. % (test_summary, os.path.basename(current_cmd[0]),
  214. e.strerror),
  215. sys.stderr, self.log, self.parent_log)
  216. raise TestException
  217. if retcode < 0:
  218. self.log.write(temp_log.getvalue())
  219. multilog("%sFAIL: Test terminated by signal %s\n\n"
  220. % (test_summary, str_signal(-retcode)),
  221. sys.stderr, self.log, self.parent_log)
  222. self.failed = True
  223. else:
  224. if self.log_all:
  225. self.log.write(temp_log.getvalue())
  226. multilog("%sPASS: Application exited with the code " \
  227. "'%d'\n\n" % (test_summary, retcode),
  228. sys.stdout, self.log, self.parent_log)
  229. temp_log.close()
  230. os.remove('copy.img')
  231. def finish(self):
  232. """Restore the test environment after a test execution."""
  233. self.log.close()
  234. self.parent_log.close()
  235. os.chdir(self.init_path)
  236. if self.cleanup and not self.failed:
  237. shutil.rmtree(self.current_dir)
  238. if __name__ == '__main__':
  239. def usage():
  240. print("""
  241. Usage: runner.py [OPTION...] TEST_DIR IMG_GENERATOR
  242. Set up test environment in TEST_DIR and run a test in it. A module for
  243. test image generation should be specified via IMG_GENERATOR.
  244. Example:
  245. runner.py -c '[["qemu-img", "info", "$test_img"]]' /tmp/test qcow2
  246. Optional arguments:
  247. -h, --help display this help and exit
  248. -d, --duration=NUMBER finish tests after NUMBER of seconds
  249. -c, --command=JSON run tests for all commands specified in
  250. the JSON array
  251. -s, --seed=STRING seed for a test image generation,
  252. by default will be generated randomly
  253. --config=JSON take fuzzer configuration from the JSON
  254. array
  255. -k, --keep_passed don't remove folders of passed tests
  256. -v, --verbose log information about passed tests
  257. JSON:
  258. '--command' accepts a JSON array of commands. Each command presents
  259. an application under test with all its parameters as a list of strings,
  260. e.g. ["qemu-io", "$test_img", "-c", "write $off $len"].
  261. Supported application aliases: 'qemu-img' and 'qemu-io'.
  262. Supported argument aliases: $test_img for the fuzzed image, $off
  263. for an offset, $len for length.
  264. Values for $off and $len will be generated based on the virtual disk
  265. size of the fuzzed image.
  266. Paths to 'qemu-img' and 'qemu-io' are retrevied from 'QEMU_IMG' and
  267. 'QEMU_IO' environment variables.
  268. '--config' accepts a JSON array of fields to be fuzzed, e.g.
  269. '[["header"], ["header", "version"]]'.
  270. Each of the list elements can consist of a complex image element only
  271. as ["header"] or ["feature_name_table"] or an exact field as
  272. ["header", "version"]. In the first case random portion of the element
  273. fields will be fuzzed, in the second one the specified field will be
  274. fuzzed always.
  275. If '--config' argument is specified, fields not listed in
  276. the configuration array will not be fuzzed.
  277. """)
  278. def run_test(test_id, seed, work_dir, run_log, cleanup, log_all,
  279. command, fuzz_config):
  280. """Setup environment for one test and execute this test."""
  281. try:
  282. test = TestEnv(test_id, seed, work_dir, run_log, cleanup,
  283. log_all)
  284. except TestException:
  285. sys.exit(1)
  286. # Python 2.4 doesn't support 'finally' and 'except' in the same 'try'
  287. # block
  288. try:
  289. try:
  290. test.execute(command, fuzz_config)
  291. except TestException:
  292. sys.exit(1)
  293. finally:
  294. test.finish()
  295. def should_continue(duration, start_time):
  296. """Return True if a new test can be started and False otherwise."""
  297. current_time = int(time.time())
  298. return (duration is None) or (current_time - start_time < duration)
  299. try:
  300. opts, args = getopt.gnu_getopt(sys.argv[1:], 'c:hs:kvd:',
  301. ['command=', 'help', 'seed=', 'config=',
  302. 'keep_passed', 'verbose', 'duration='])
  303. except getopt.error as e:
  304. print("Error: %s\n\nTry 'runner.py --help' for more information" % e, file=sys.stderr)
  305. sys.exit(1)
  306. command = None
  307. cleanup = True
  308. log_all = False
  309. seed = None
  310. config = None
  311. duration = None
  312. for opt, arg in opts:
  313. if opt in ('-h', '--help'):
  314. usage()
  315. sys.exit()
  316. elif opt in ('-c', '--command'):
  317. try:
  318. command = json.loads(arg)
  319. except (TypeError, ValueError, NameError) as e:
  320. print("Error: JSON array of test commands cannot be loaded.\n" \
  321. "Reason: %s" % e, file=sys.stderr)
  322. sys.exit(1)
  323. elif opt in ('-k', '--keep_passed'):
  324. cleanup = False
  325. elif opt in ('-v', '--verbose'):
  326. log_all = True
  327. elif opt in ('-s', '--seed'):
  328. seed = arg
  329. elif opt in ('-d', '--duration'):
  330. duration = int(arg)
  331. elif opt == '--config':
  332. try:
  333. config = json.loads(arg)
  334. except (TypeError, ValueError, NameError) as e:
  335. print("Error: JSON array with the fuzzer configuration cannot" \
  336. " be loaded\nReason: %s" % e, file=sys.stderr)
  337. sys.exit(1)
  338. if not len(args) == 2:
  339. print("Expected two parameters\nTry 'runner.py --help'" \
  340. " for more information.", file=sys.stderr)
  341. sys.exit(1)
  342. work_dir = os.path.realpath(args[0])
  343. # run_log is created in 'main', because multiple tests are expected to
  344. # log in it
  345. run_log = os.path.join(work_dir, 'run.log')
  346. # Add the path to the image generator module to sys.path
  347. sys.path.append(os.path.realpath(os.path.dirname(args[1])))
  348. # Remove a script extension from image generator module if any
  349. generator_name = os.path.splitext(os.path.basename(args[1]))[0]
  350. try:
  351. image_generator = __import__(generator_name)
  352. except ImportError as e:
  353. print("Error: The image generator '%s' cannot be imported.\n" \
  354. "Reason: %s" % (generator_name, e), file=sys.stderr)
  355. sys.exit(1)
  356. # Enable core dumps
  357. resource.setrlimit(resource.RLIMIT_CORE, (-1, -1))
  358. # If a seed is specified, only one test will be executed.
  359. # Otherwise runner will terminate after a keyboard interruption
  360. start_time = int(time.time())
  361. test_id = count(1)
  362. while should_continue(duration, start_time):
  363. try:
  364. run_test(str(next(test_id)), seed, work_dir, run_log, cleanup,
  365. log_all, command, config)
  366. except (KeyboardInterrupt, SystemExit):
  367. sys.exit(1)
  368. if seed is not None:
  369. break