blob: 241ef712e7e3822a765009043baea52077186161 [file] [log] [blame]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001#!/usr/bin/env python
2# coding=utf-8
3# Copyright (c) 2012 The Chromium Authors. All rights reserved.
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6
7"""Traces an executable and its child processes and extract the files accessed
8by them.
9
10The implementation uses OS-specific API. The native Kernel logger and the ETL
11interface is used on Windows. Dtrace is used on OSX. Strace is used otherwise.
12The OS-specific implementation is hidden in an 'API' interface.
13
14The results are embedded in a Results instance. The tracing is done in two
15phases, the first is to do the actual trace and generate an
16implementation-specific log file. Then the log file is parsed to extract the
17information, including the individual child processes and the files accessed
18from the log.
19"""
20
21import codecs
22import csv
maruel@chromium.org9e56b592013-05-21 16:56:05 +000023import errno
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +000024import getpass
25import glob
26import json
27import logging
28import optparse
29import os
30import re
maruel@chromium.org9b2112a2013-04-05 18:01:02 +000031import stat
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +000032import subprocess
33import sys
34import tempfile
35import threading
36import time
maruel@chromium.org9b2112a2013-04-05 18:01:02 +000037import unicodedata
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +000038import weakref
39
40## OS-specific imports
41
42if sys.platform == 'win32':
43 from ctypes.wintypes import byref, create_unicode_buffer, c_int, c_wchar_p
44 from ctypes.wintypes import windll, FormatError # pylint: disable=E0611
45 from ctypes.wintypes import GetLastError # pylint: disable=E0611
46elif sys.platform == 'darwin':
47 import Carbon.File # pylint: disable=F0401
48 import MacOS # pylint: disable=F0401
49
50
51BASE_DIR = os.path.dirname(os.path.abspath(__file__))
52ROOT_DIR = os.path.dirname(os.path.dirname(BASE_DIR))
53
54
55class TracingFailure(Exception):
56 """An exception occured during tracing."""
57 def __init__(self, description, pid, line_number, line, *args):
58 super(TracingFailure, self).__init__(
59 description, pid, line_number, line, *args)
60 self.description = description
61 self.pid = pid
62 self.line_number = line_number
63 self.line = line
64 self.extra = args
65
66 def __str__(self):
67 out = self.description
68 if self.pid:
69 out += '\npid: %d' % self.pid
70 if self.line_number:
71 out += '\nline: %d' % self.line_number
72 if self.line:
73 out += '\n%s' % self.line
74 if self.extra:
75 out += '\n' + ', '.join(map(str, filter(None, self.extra)))
76 return out
77
78
79## OS-specific functions
80
81if sys.platform == 'win32':
82 def QueryDosDevice(drive_letter):
83 """Returns the Windows 'native' path for a DOS drive letter."""
84 assert re.match(r'^[a-zA-Z]:$', drive_letter), drive_letter
maruel@chromium.org306e0e72012-11-02 18:22:03 +000085 assert isinstance(drive_letter, unicode)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +000086 # Guesswork. QueryDosDeviceW never returns the required number of bytes.
87 chars = 1024
maruel@chromium.org306e0e72012-11-02 18:22:03 +000088 drive_letter = drive_letter
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +000089 p = create_unicode_buffer(chars)
90 if 0 == windll.kernel32.QueryDosDeviceW(drive_letter, p, chars):
91 err = GetLastError()
92 if err:
93 # pylint: disable=E0602
maruel@chromium.orgf37c2472012-11-02 20:06:13 +000094 msg = u'QueryDosDevice(%s): %s (%d)' % (
95 drive_letter, FormatError(err), err)
96 raise WindowsError(err, msg.encode('utf-8'))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +000097 return p.value
98
99
100 def GetShortPathName(long_path):
101 """Returns the Windows short path equivalent for a 'long' path."""
maruel@chromium.org68bb1482013-02-15 22:36:24 +0000102 assert isinstance(long_path, unicode), repr(long_path)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000103 # Adds '\\\\?\\' when given an absolute path so the MAX_PATH (260) limit is
104 # not enforced.
105 if os.path.isabs(long_path) and not long_path.startswith('\\\\?\\'):
106 long_path = '\\\\?\\' + long_path
107 chars = windll.kernel32.GetShortPathNameW(long_path, None, 0)
108 if chars:
109 p = create_unicode_buffer(chars)
110 if windll.kernel32.GetShortPathNameW(long_path, p, chars):
111 return p.value
112
113 err = GetLastError()
114 if err:
115 # pylint: disable=E0602
maruel@chromium.orgf37c2472012-11-02 20:06:13 +0000116 msg = u'GetShortPathName(%s): %s (%d)' % (
117 long_path, FormatError(err), err)
118 raise WindowsError(err, msg.encode('utf-8'))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000119
120
121 def GetLongPathName(short_path):
122 """Returns the Windows long path equivalent for a 'short' path."""
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000123 assert isinstance(short_path, unicode)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000124 # Adds '\\\\?\\' when given an absolute path so the MAX_PATH (260) limit is
125 # not enforced.
126 if os.path.isabs(short_path) and not short_path.startswith('\\\\?\\'):
127 short_path = '\\\\?\\' + short_path
128 chars = windll.kernel32.GetLongPathNameW(short_path, None, 0)
129 if chars:
130 p = create_unicode_buffer(chars)
131 if windll.kernel32.GetLongPathNameW(short_path, p, chars):
132 return p.value
133
134 err = GetLastError()
135 if err:
136 # pylint: disable=E0602
maruel@chromium.orgf37c2472012-11-02 20:06:13 +0000137 msg = u'GetLongPathName(%s): %s (%d)' % (
138 short_path, FormatError(err), err)
139 raise WindowsError(err, msg.encode('utf-8'))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000140
141
142 def get_current_encoding():
143 """Returns the 'ANSI' code page associated to the process."""
144 return 'cp%d' % int(windll.kernel32.GetACP())
145
146
147 class DosDriveMap(object):
148 """Maps \Device\HarddiskVolumeN to N: on Windows."""
149 # Keep one global cache.
150 _MAPPING = {}
151
152 def __init__(self):
153 """Lazy loads the cache."""
154 if not self._MAPPING:
155 # This is related to UNC resolver on windows. Ignore that.
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000156 self._MAPPING[u'\\Device\\Mup'] = None
157 self._MAPPING[u'\\SystemRoot'] = os.environ[u'SystemRoot']
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000158
159 for letter in (chr(l) for l in xrange(ord('C'), ord('Z')+1)):
160 try:
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000161 letter = u'%s:' % letter
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000162 mapped = QueryDosDevice(letter)
163 if mapped in self._MAPPING:
164 logging.warn(
165 ('Two drives: \'%s\' and \'%s\', are mapped to the same disk'
166 '. Drive letters are a user-mode concept and the kernel '
167 'traces only have NT path, so all accesses will be '
168 'associated with the first drive letter, independent of the '
169 'actual letter used by the code') % (
170 self._MAPPING[mapped], letter))
171 else:
172 self._MAPPING[mapped] = letter
173 except WindowsError: # pylint: disable=E0602
174 pass
175
176 def to_win32(self, path):
177 """Converts a native NT path to Win32/DOS compatible path."""
178 match = re.match(r'(^\\Device\\[a-zA-Z0-9]+)(\\.*)?$', path)
179 if not match:
180 raise ValueError(
181 'Can\'t convert %s into a Win32 compatible path' % path,
182 path)
183 if not match.group(1) in self._MAPPING:
184 # Unmapped partitions may be accessed by windows for the
185 # fun of it while the test is running. Discard these.
186 return None
187 drive = self._MAPPING[match.group(1)]
188 if not drive or not match.group(2):
189 return drive
190 return drive + match.group(2)
191
192
193 def isabs(path):
194 """Accepts X: as an absolute path, unlike python's os.path.isabs()."""
195 return os.path.isabs(path) or len(path) == 2 and path[1] == ':'
196
197
csharp@chromium.orgf2eacff2013-04-04 14:20:20 +0000198 def find_item_native_case(root, item):
199 """Gets the native path case of a single item based at root_path."""
200 if item == '..':
201 return item
202
203 root = get_native_path_case(root)
204 return os.path.basename(get_native_path_case(os.path.join(root, item)))
205
206
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000207 def get_native_path_case(p):
208 """Returns the native path case for an existing file.
209
210 On Windows, removes any leading '\\?\'.
211 """
maruel@chromium.orgd1e9fbd2013-02-26 14:42:24 +0000212 assert isinstance(p, unicode), repr(p)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000213 if not isabs(p):
214 raise ValueError(
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +0000215 'get_native_path_case(%r): Require an absolute path' % p, p)
216
maruel@chromium.org037758d2012-12-10 17:59:46 +0000217 # Make sure it is normalized to os.path.sep. Do not do it here to keep the
218 # function fast
219 assert '/' not in p, p
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +0000220 suffix = ''
221 count = p.count(':')
222 if count > 1:
223 # This means it has an alternate-data stream. There could be 3 ':', since
224 # it could be the $DATA datastream of an ADS. Split the whole ADS suffix
225 # off and add it back afterward. There is no way to know the native path
226 # case of an alternate data stream.
227 items = p.split(':')
228 p = ':'.join(items[0:2])
229 suffix = ''.join(':' + i for i in items[2:])
230
maruel@chromium.org42b091d2013-04-02 22:27:02 +0000231 # TODO(maruel): Use os.path.normpath?
232 if p.endswith('.\\'):
233 p = p[:-2]
234
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000235 # Windows used to have an option to turn on case sensitivity on non Win32
236 # subsystem but that's out of scope here and isn't supported anymore.
237 # Go figure why GetShortPathName() is needed.
238 try:
239 out = GetLongPathName(GetShortPathName(p))
240 except OSError, e:
241 if e.args[0] in (2, 3, 5):
242 # The path does not exist. Try to recurse and reconstruct the path.
243 base = os.path.dirname(p)
244 rest = os.path.basename(p)
245 return os.path.join(get_native_path_case(base), rest)
246 raise
247 if out.startswith('\\\\?\\'):
248 out = out[4:]
249 # Always upper case the first letter since GetLongPathName() will return the
250 # drive letter in the case it was given.
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +0000251 return out[0].upper() + out[1:] + suffix
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000252
253
254 def CommandLineToArgvW(command_line):
255 """Splits a commandline into argv using CommandLineToArgvW()."""
256 # http://msdn.microsoft.com/library/windows/desktop/bb776391.aspx
257 size = c_int()
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000258 assert isinstance(command_line, unicode)
259 ptr = windll.shell32.CommandLineToArgvW(command_line, byref(size))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000260 try:
261 return [arg for arg in (c_wchar_p * size.value).from_address(ptr)]
262 finally:
263 windll.kernel32.LocalFree(ptr)
264
265
266elif sys.platform == 'darwin':
267
268
269 # On non-windows, keep the stdlib behavior.
270 isabs = os.path.isabs
271
272
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000273 def _native_case(p):
274 """Gets the native path case. Warning: this function resolves symlinks."""
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000275 try:
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000276 rel_ref, _ = Carbon.File.FSPathMakeRef(p.encode('utf-8'))
277 # The OSX underlying code uses NFD but python strings are in NFC. This
278 # will cause issues with os.listdir() for example. Since the dtrace log
279 # *is* in NFC, normalize it here.
csharp@chromium.org5a2c3732013-03-07 14:44:56 +0000280 out = unicodedata.normalize(
281 'NFC', rel_ref.FSRefMakePath().decode('utf-8'))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000282 if p.endswith(os.path.sep) and not out.endswith(os.path.sep):
283 return out + os.path.sep
284 return out
285 except MacOS.Error, e:
286 if e.args[0] in (-43, -120):
287 # The path does not exist. Try to recurse and reconstruct the path.
288 # -43 means file not found.
289 # -120 means directory not found.
290 base = os.path.dirname(p)
291 rest = os.path.basename(p)
csharp@chromium.org5a2c3732013-03-07 14:44:56 +0000292 return os.path.join(_native_case(base), rest)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000293 raise OSError(
294 e.args[0], 'Failed to get native path for %s' % p, p, e.args[1])
295
296
297 def _split_at_symlink_native(base_path, rest):
298 """Returns the native path for a symlink."""
299 base, symlink, rest = split_at_symlink(base_path, rest)
300 if symlink:
301 if not base_path:
302 base_path = base
303 else:
304 base_path = safe_join(base_path, base)
csharp@chromium.orgf2eacff2013-04-04 14:20:20 +0000305 symlink = find_item_native_case(base_path, symlink)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000306 return base, symlink, rest
307
308
csharp@chromium.orgf2eacff2013-04-04 14:20:20 +0000309 def find_item_native_case(root_path, item):
310 """Gets the native path case of a single item based at root_path.
311
312 There is no API to get the native path case of symlinks on OSX. So it
313 needs to be done the slow way.
314 """
315 if item == '..':
316 return item
317
318 item = item.lower()
319 for element in os.listdir(root_path):
320 if element.lower() == item:
321 return element
322
323
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000324 def get_native_path_case(path):
325 """Returns the native path case for an existing file.
326
327 Technically, it's only HFS+ on OSX that is case preserving and
328 insensitive. It's the default setting on HFS+ but can be changed.
329 """
maruel@chromium.orgd1e9fbd2013-02-26 14:42:24 +0000330 assert isinstance(path, unicode), repr(path)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000331 if not isabs(path):
332 raise ValueError(
maruel@chromium.orgd1e9fbd2013-02-26 14:42:24 +0000333 'get_native_path_case(%r): Require an absolute path' % path, path)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000334 if path.startswith('/dev'):
335 # /dev is not visible from Carbon, causing an exception.
336 return path
337
338 # Starts assuming there is no symlink along the path.
339 resolved = _native_case(path)
csharp@chromium.orgf2eacff2013-04-04 14:20:20 +0000340 if path.lower() in (resolved.lower(), resolved.lower() + './'):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000341 # This code path is incredibly faster.
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000342 logging.debug('get_native_path_case(%s) = %s' % (path, resolved))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000343 return resolved
344
345 # There was a symlink, process it.
346 base, symlink, rest = _split_at_symlink_native(None, path)
347 assert symlink, (path, base, symlink, rest, resolved)
348 prev = base
349 base = safe_join(_native_case(base), symlink)
350 assert len(base) > len(prev)
351 while rest:
352 prev = base
353 relbase, symlink, rest = _split_at_symlink_native(base, rest)
354 base = safe_join(base, relbase)
355 assert len(base) > len(prev), (prev, base, symlink)
356 if symlink:
357 base = safe_join(base, symlink)
358 assert len(base) > len(prev), (prev, base, symlink)
359 # Make sure no symlink was resolved.
360 assert base.lower() == path.lower(), (base, path)
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000361 logging.debug('get_native_path_case(%s) = %s' % (path, base))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000362 return base
363
364
365else: # OSes other than Windows and OSX.
366
367
368 # On non-windows, keep the stdlib behavior.
369 isabs = os.path.isabs
370
371
csharp@chromium.orgf2eacff2013-04-04 14:20:20 +0000372 def find_item_native_case(root, item):
373 """Gets the native path case of a single item based at root_path."""
374 if item == '..':
375 return item
376
377 root = get_native_path_case(root)
378 return os.path.basename(get_native_path_case(os.path.join(root, item)))
379
380
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000381 def get_native_path_case(path):
382 """Returns the native path case for an existing file.
383
384 On OSes other than OSX and Windows, assume the file system is
385 case-sensitive.
386
387 TODO(maruel): This is not strictly true. Implement if necessary.
388 """
maruel@chromium.orgd1e9fbd2013-02-26 14:42:24 +0000389 assert isinstance(path, unicode), repr(path)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000390 if not isabs(path):
391 raise ValueError(
maruel@chromium.orgd1e9fbd2013-02-26 14:42:24 +0000392 'get_native_path_case(%r): Require an absolute path' % path, path)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000393 # Give up on cygwin, as GetLongPathName() can't be called.
394 # Linux traces tends to not be normalized so use this occasion to normalize
395 # it. This function implementation already normalizes the path on the other
396 # OS so this needs to be done here to be coherent between OSes.
397 out = os.path.normpath(path)
398 if path.endswith(os.path.sep) and not out.endswith(os.path.sep):
399 return out + os.path.sep
400 return out
401
402
403if sys.platform != 'win32': # All non-Windows OSes.
404
405
406 def safe_join(*args):
407 """Joins path elements like os.path.join() but doesn't abort on absolute
408 path.
409
410 os.path.join('foo', '/bar') == '/bar'
411 but safe_join('foo', '/bar') == 'foo/bar'.
412 """
413 out = ''
414 for element in args:
415 if element.startswith(os.path.sep):
416 if out.endswith(os.path.sep):
417 out += element[1:]
418 else:
419 out += element
420 else:
421 if out.endswith(os.path.sep):
422 out += element
423 else:
424 out += os.path.sep + element
425 return out
426
427
428 def split_at_symlink(base_dir, relfile):
429 """Scans each component of relfile and cut the string at the symlink if
430 there is any.
431
432 Returns a tuple (base_path, symlink, rest), with symlink == rest == None if
433 not symlink was found.
434 """
435 if base_dir:
436 assert relfile
437 assert os.path.isabs(base_dir)
438 index = 0
439 else:
440 assert os.path.isabs(relfile)
441 index = 1
442
443 def at_root(rest):
444 if base_dir:
445 return safe_join(base_dir, rest)
446 return rest
447
448 while True:
449 try:
450 index = relfile.index(os.path.sep, index)
451 except ValueError:
452 index = len(relfile)
453 full = at_root(relfile[:index])
454 if os.path.islink(full):
455 # A symlink!
456 base = os.path.dirname(relfile[:index])
457 symlink = os.path.basename(relfile[:index])
458 rest = relfile[index:]
459 logging.debug(
460 'split_at_symlink(%s, %s) -> (%s, %s, %s)' %
461 (base_dir, relfile, base, symlink, rest))
462 return base, symlink, rest
463 if index == len(relfile):
464 break
465 index += 1
466 return relfile, None, None
467
468
maruel@chromium.orgac36fb72013-05-21 14:50:53 +0000469class Unbuffered(object):
470 """Disable buffering on a file object."""
471 def __init__(self, stream):
472 self.stream = stream
473
474 def write(self, data):
475 self.stream.write(data)
476 if '\n' in data:
477 self.stream.flush()
478
479 def __getattr__(self, attr):
480 return getattr(self.stream, attr)
481
482
483def disable_buffering():
484 """Makes this process and child processes stdout unbuffered."""
485 if not os.environ.get('PYTHONUNBUFFERED'):
486 # Since sys.stdout is a C++ object, it's impossible to do
487 # sys.stdout.write = lambda...
488 sys.stdout = Unbuffered(sys.stdout)
489 os.environ['PYTHONUNBUFFERED'] = 'x'
490
491
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000492def fix_python_path(cmd):
493 """Returns the fixed command line to call the right python executable."""
494 out = cmd[:]
495 if out[0] == 'python':
496 out[0] = sys.executable
497 elif out[0].endswith('.py'):
498 out.insert(0, sys.executable)
499 return out
500
501
502def create_thunk():
503 handle, name = tempfile.mkstemp(prefix='trace_inputs_thunk', suffix='.py')
504 os.write(
505 handle,
506 (
507 'import subprocess\n'
508 'import sys\n'
509 'sys.exit(subprocess.call(sys.argv[2:]))\n'
510 ))
511 os.close(handle)
512 return name
513
514
515def strace_process_quoted_arguments(text):
516 """Extracts quoted arguments on a string and return the arguments as a list.
517
518 Implemented as an automaton. Supports incomplete strings in the form
519 '"foo"...'.
520
521 Example:
522 With text = '"foo", "bar"', the function will return ['foo', 'bar']
523
524 TODO(maruel): Implement escaping.
525 """
526 # All the possible states of the DFA.
527 ( NEED_QUOTE, # Begining of a new arguments.
528 INSIDE_STRING, # Inside an argument.
529 ESCAPED, # Found a '\' inside a quote. Treat the next char as-is.
530 NEED_COMMA_OR_DOT, # Right after the closing quote of an argument. Could be
531 # a serie of 3 dots or a comma.
532 NEED_SPACE, # Right after a comma
533 NEED_DOT_2, # Found a dot, need a second one.
534 NEED_DOT_3, # Found second dot, need a third one.
535 NEED_COMMA, # Found third dot, need a comma.
536 ) = range(8)
537
538 state = NEED_QUOTE
539 out = []
540 for index, char in enumerate(text):
541 if char == '"':
542 if state == NEED_QUOTE:
543 state = INSIDE_STRING
544 # A new argument was found.
545 out.append('')
546 elif state == INSIDE_STRING:
547 # The argument is now closed.
548 state = NEED_COMMA_OR_DOT
549 elif state == ESCAPED:
550 out[-1] += char
551 state = INSIDE_STRING
552 else:
553 raise ValueError(
maruel@chromium.org588e09d2013-05-21 14:38:57 +0000554 'Can\'t process char \'%s\' at column %d for: %r' % (
555 char, index, text),
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000556 index,
557 text)
558 elif char == ',':
559 if state in (NEED_COMMA_OR_DOT, NEED_COMMA):
560 state = NEED_SPACE
561 elif state == INSIDE_STRING:
562 out[-1] += char
563 elif state == ESCAPED:
564 out[-1] += char
565 state = INSIDE_STRING
566 else:
567 raise ValueError(
maruel@chromium.org588e09d2013-05-21 14:38:57 +0000568 'Can\'t process char \'%s\' at column %d for: %r' % (
569 char, index, text),
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000570 index,
571 text)
572 elif char == ' ':
573 if state == NEED_SPACE:
574 state = NEED_QUOTE
575 elif state == INSIDE_STRING:
576 out[-1] += char
577 elif state == ESCAPED:
578 out[-1] += char
579 state = INSIDE_STRING
580 else:
581 raise ValueError(
maruel@chromium.org588e09d2013-05-21 14:38:57 +0000582 'Can\'t process char \'%s\' at column %d for: %r' % (
583 char, index, text),
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000584 index,
585 text)
586 elif char == '.':
maruel@chromium.org588e09d2013-05-21 14:38:57 +0000587 if state in (NEED_QUOTE, NEED_COMMA_OR_DOT):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000588 # The string is incomplete, this mean the strace -s flag should be
589 # increased.
maruel@chromium.org588e09d2013-05-21 14:38:57 +0000590 # For NEED_QUOTE, the input string would look like '"foo", ...'.
591 # For NEED_COMMA_OR_DOT, the input string would look like '"foo"...'
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000592 state = NEED_DOT_2
593 elif state == NEED_DOT_2:
594 state = NEED_DOT_3
595 elif state == NEED_DOT_3:
596 state = NEED_COMMA
597 elif state == INSIDE_STRING:
598 out[-1] += char
599 elif state == ESCAPED:
600 out[-1] += char
601 state = INSIDE_STRING
602 else:
603 raise ValueError(
maruel@chromium.org588e09d2013-05-21 14:38:57 +0000604 'Can\'t process char \'%s\' at column %d for: %r' % (
605 char, index, text),
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000606 index,
607 text)
608 elif char == '\\':
609 if state == ESCAPED:
610 out[-1] += char
611 state = INSIDE_STRING
612 elif state == INSIDE_STRING:
613 state = ESCAPED
614 else:
615 raise ValueError(
maruel@chromium.org588e09d2013-05-21 14:38:57 +0000616 'Can\'t process char \'%s\' at column %d for: %r' % (
617 char, index, text),
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000618 index,
619 text)
620 else:
621 if state == INSIDE_STRING:
622 out[-1] += char
623 else:
624 raise ValueError(
maruel@chromium.org588e09d2013-05-21 14:38:57 +0000625 'Can\'t process char \'%s\' at column %d for: %r' % (
626 char, index, text),
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000627 index,
628 text)
629 if state not in (NEED_COMMA, NEED_COMMA_OR_DOT):
630 raise ValueError(
631 'String is incorrectly terminated: %r' % text,
632 text)
633 return out
634
635
636def read_json(filepath):
637 with open(filepath, 'r') as f:
638 return json.load(f)
639
640
641def write_json(filepath_or_handle, data, dense):
642 """Writes data into filepath or file handle encoded as json.
643
644 If dense is True, the json is packed. Otherwise, it is human readable.
645 """
646 if hasattr(filepath_or_handle, 'write'):
647 if dense:
648 filepath_or_handle.write(
649 json.dumps(data, sort_keys=True, separators=(',',':')))
650 else:
651 filepath_or_handle.write(json.dumps(data, sort_keys=True, indent=2))
652 else:
653 with open(filepath_or_handle, 'wb') as f:
654 if dense:
655 json.dump(data, f, sort_keys=True, separators=(',',':'))
656 else:
657 json.dump(data, f, sort_keys=True, indent=2)
658
659
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000660def assert_is_renderable(pseudo_string):
661 """Asserts the input is a valid object to be processed by render()."""
662 assert (
663 isinstance(pseudo_string, (None.__class__, unicode)) or
664 hasattr(pseudo_string, 'render')), repr(pseudo_string)
665
666
667def render(pseudo_string):
668 """Converts the pseudo-string to an unicode string."""
669 assert_is_renderable(pseudo_string)
670 if isinstance(pseudo_string, (None.__class__, unicode)):
671 return pseudo_string
672 return pseudo_string.render()
673
674
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000675class Results(object):
676 """Results of a trace session."""
677
678 class _TouchedObject(object):
679 """Something, a file or a directory, that was accessed."""
680 def __init__(self, root, path, tainted, size, nb_files):
681 logging.debug(
682 '%s(%s, %s, %s, %s, %s)' %
683 (self.__class__.__name__, root, path, tainted, size, nb_files))
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000684 assert_is_renderable(root)
685 assert_is_renderable(path)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000686 self.root = root
687 self.path = path
688 self.tainted = tainted
689 self.nb_files = nb_files
690 # Can be used as a cache or a default value, depending on context.
691 self._size = size
692 # These are cache only.
693 self._real_path = None
694
695 # Check internal consistency.
696 assert path, path
697 assert tainted or bool(root) != bool(isabs(path)), (root, path)
698 assert tainted or (
699 not os.path.exists(self.full_path) or
700 (self.full_path == get_native_path_case(self.full_path))), (
701 tainted, self.full_path, get_native_path_case(self.full_path))
702
703 @property
704 def existent(self):
705 return self.size != -1
706
707 @property
708 def full_path(self):
709 if self.root:
710 return os.path.join(self.root, self.path)
711 return self.path
712
713 @property
714 def real_path(self):
715 """Returns the path with symlinks resolved."""
716 if not self._real_path:
717 self._real_path = os.path.realpath(self.full_path)
718 return self._real_path
719
720 @property
721 def size(self):
722 """File's size. -1 is not existent."""
723 if self._size is None and not self.tainted:
724 try:
725 self._size = os.stat(self.full_path).st_size
726 except OSError:
727 self._size = -1
728 return self._size
729
730 def flatten(self):
731 """Returns a dict representing this object.
732
733 A 'size' of 0 means the file was only touched and not read.
734 """
735 return {
736 'path': self.path,
737 'size': self.size,
738 }
739
740 def replace_variables(self, variables):
741 """Replaces the root of this File with one of the variables if it matches.
742
743 If a variable replacement occurs, the cloned object becomes tainted.
744 """
745 for variable, root_path in variables.iteritems():
746 if self.path.startswith(root_path):
747 return self._clone(
748 self.root, variable + self.path[len(root_path):], True)
749 # No need to clone, returns ourself.
750 return self
751
752 def strip_root(self, root):
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000753 """Returns a clone of itself with 'root' stripped off.
754
755 Note that the file is kept if it is either accessible from a symlinked
756 path that was used to access the file or through the real path.
757 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000758 # Check internal consistency.
759 assert self.tainted or (isabs(root) and root.endswith(os.path.sep)), root
760 if not self.full_path.startswith(root):
761 # Now try to resolve the symlinks to see if it can be reached this way.
762 # Only try *after* trying without resolving symlink.
763 if not self.real_path.startswith(root):
764 return None
765 path = self.real_path
766 else:
767 path = self.full_path
768 return self._clone(root, path[len(root):], self.tainted)
769
770 def _clone(self, new_root, new_path, tainted):
771 raise NotImplementedError(self.__class__.__name__)
772
773 class File(_TouchedObject):
774 """A file that was accessed. May not be present anymore.
775
776 If tainted is true, it means it is not a real path anymore as a variable
777 replacement occured.
778
maruel@chromium.orge5c17132012-11-21 18:18:46 +0000779 If only_touched is True, this means the file was probed for existence, and
780 it is existent, but was never _opened_. If only_touched is True, the file
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000781 must have existed.
782 """
783 def __init__(self, root, path, tainted, size):
784 super(Results.File, self).__init__(root, path, tainted, size, 1)
785
786 def _clone(self, new_root, new_path, tainted):
787 """Clones itself keeping meta-data."""
788 # Keep the self.size and self._real_path caches for performance reason. It
789 # is also important when the file becomes tainted (with a variable instead
790 # of the real path) since self.path is not an on-disk path anymore so
791 # out._size cannot be updated.
792 out = self.__class__(new_root, new_path, tainted, self.size)
793 out._real_path = self._real_path
794 return out
795
796 class Directory(_TouchedObject):
797 """A directory of files. Must exist."""
798 def __init__(self, root, path, tainted, size, nb_files):
799 """path='.' is a valid value and must be handled appropriately."""
800 assert not path.endswith(os.path.sep), path
801 super(Results.Directory, self).__init__(
802 root, path + os.path.sep, tainted, size, nb_files)
803 # For a Directory instance, self.size is not a cache, it's an actual value
804 # that is never modified and represents the total size of the files
805 # contained in this directory. It is possible that the directory is empty
806 # so that size == 0; this happens if there's only an invalid symlink in
807 # it.
808
809 def flatten(self):
810 out = super(Results.Directory, self).flatten()
811 out['nb_files'] = self.nb_files
812 return out
813
814 def _clone(self, new_root, new_path, tainted):
815 """Clones itself keeping meta-data."""
816 out = self.__class__(
817 new_root,
818 new_path.rstrip(os.path.sep),
819 tainted,
820 self.size,
821 self.nb_files)
822 out._real_path = self._real_path
823 return out
824
825 class Process(object):
826 """A process that was traced.
827
828 Contains references to the files accessed by this process and its children.
829 """
830 def __init__(self, pid, files, executable, command, initial_cwd, children):
831 logging.debug('Process(%s, %d, ...)' % (pid, len(files)))
832 self.pid = pid
833 self.files = sorted(files, key=lambda x: x.path)
834 self.children = children
835 self.executable = executable
836 self.command = command
837 self.initial_cwd = initial_cwd
838
839 # Check internal consistency.
840 assert len(set(f.path for f in self.files)) == len(self.files), sorted(
841 f.path for f in self.files)
842 assert isinstance(self.children, list)
843 assert isinstance(self.files, list)
844
845 @property
846 def all(self):
847 for child in self.children:
848 for i in child.all:
849 yield i
850 yield self
851
852 def flatten(self):
853 return {
854 'children': [c.flatten() for c in self.children],
855 'command': self.command,
856 'executable': self.executable,
857 'files': [f.flatten() for f in self.files],
858 'initial_cwd': self.initial_cwd,
859 'pid': self.pid,
860 }
861
862 def strip_root(self, root):
863 assert isabs(root) and root.endswith(os.path.sep), root
864 # Loads the files after since they are constructed as objects.
865 out = self.__class__(
866 self.pid,
867 filter(None, (f.strip_root(root) for f in self.files)),
868 self.executable,
869 self.command,
870 self.initial_cwd,
871 [c.strip_root(root) for c in self.children])
872 logging.debug(
873 'strip_root(%s) %d -> %d' % (root, len(self.files), len(out.files)))
874 return out
875
876 def __init__(self, process):
877 self.process = process
878 # Cache.
879 self._files = None
880
881 def flatten(self):
882 return {
883 'root': self.process.flatten(),
884 }
885
886 @property
887 def files(self):
888 if self._files is None:
889 self._files = sorted(
890 sum((p.files for p in self.process.all), []),
891 key=lambda x: x.path)
892 return self._files
893
894 @property
895 def existent(self):
896 return [f for f in self.files if f.existent]
897
898 @property
899 def non_existent(self):
900 return [f for f in self.files if not f.existent]
901
902 def strip_root(self, root):
903 """Returns a clone with all the files outside the directory |root| removed
904 and converts all the path to be relative paths.
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000905
906 It keeps files accessible through the |root| directory or that have been
907 accessed through any symlink which points to the same directory.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000908 """
909 # Resolve any symlink
910 root = os.path.realpath(root)
911 root = get_native_path_case(root).rstrip(os.path.sep) + os.path.sep
912 logging.debug('strip_root(%s)' % root)
913 return Results(self.process.strip_root(root))
914
915
916class ApiBase(object):
917 """OS-agnostic API to trace a process and its children."""
918 class Context(object):
919 """Processes one log line at a time and keeps the list of traced processes.
920
921 The parsing is complicated by the fact that logs are traced out of order for
922 strace but in-order for dtrace and logman. In addition, on Windows it is
923 very frequent that processids are reused so a flat list cannot be used. But
924 at the same time, it is impossible to faithfully construct a graph when the
925 logs are processed out of order. So both a tree and a flat mapping are used,
926 the tree is the real process tree, while the flat mapping stores the last
927 valid process for the corresponding processid. For the strace case, the
928 tree's head is guessed at the last moment.
929 """
930 class Process(object):
931 """Keeps context for one traced child process.
932
933 Logs all the files this process touched. Ignores directories.
934 """
935 def __init__(self, blacklist, pid, initial_cwd):
936 # Check internal consistency.
937 assert isinstance(pid, int), repr(pid)
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000938 assert_is_renderable(initial_cwd)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000939 self.pid = pid
940 # children are Process instances.
941 self.children = []
942 self.initial_cwd = initial_cwd
943 self.cwd = None
944 self.files = set()
945 self.only_touched = set()
946 self.executable = None
947 self.command = None
948 self._blacklist = blacklist
949
950 def to_results_process(self):
951 """Resolves file case sensitivity and or late-bound strings."""
952 # When resolving files, it's normal to get dupe because a file could be
953 # opened multiple times with different case. Resolve the deduplication
954 # here.
955 def fix_path(x):
956 """Returns the native file path case.
957
958 Converts late-bound strings.
959 """
960 if not x:
961 # Do not convert None instance to 'None'.
962 return x
maruel@chromium.org306e0e72012-11-02 18:22:03 +0000963 x = render(x)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +0000964 if os.path.isabs(x):
965 # If the path is not absolute, which tends to happen occasionally on
966 # Windows, it is not possible to get the native path case so ignore
967 # that trace. It mostly happens for 'executable' value.
968 x = get_native_path_case(x)
969 return x
970
971 def fix_and_blacklist_path(x):
972 x = fix_path(x)
973 if not x:
974 return
975 # The blacklist needs to be reapplied, since path casing could
976 # influence blacklisting.
977 if self._blacklist(x):
978 return
979 return x
980
981 # Filters out directories. Some may have passed through.
982 files = set(f for f in map(fix_and_blacklist_path, self.files) if f)
983 only_touched = set(
984 f for f in map(fix_and_blacklist_path, self.only_touched) if f)
985 only_touched -= files
986
987 files = [
988 Results.File(None, f, False, None) for f in files
989 if not os.path.isdir(f)
990 ]
991 # Using 0 as size means the file's content is ignored since the file was
992 # never opened for I/O.
993 files.extend(
994 Results.File(None, f, False, 0) for f in only_touched
995 if not os.path.isdir(f)
996 )
997 return Results.Process(
998 self.pid,
999 files,
1000 fix_path(self.executable),
1001 self.command,
1002 fix_path(self.initial_cwd),
1003 [c.to_results_process() for c in self.children])
1004
1005 def add_file(self, filepath, touch_only):
1006 """Adds a file if it passes the blacklist."""
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001007 if self._blacklist(render(filepath)):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001008 return
1009 logging.debug('add_file(%d, %s, %s)' % (self.pid, filepath, touch_only))
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001010 # Note that filepath and not render(filepath) is added. It is
1011 # because filepath could be something else than a string, like a
1012 # RelativePath instance for dtrace logs.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001013 if touch_only:
1014 self.only_touched.add(filepath)
1015 else:
1016 self.files.add(filepath)
1017
1018 def __init__(self, blacklist):
1019 self.blacklist = blacklist
1020 # Initial process.
1021 self.root_process = None
1022 # dict to accelerate process lookup, to not have to lookup the whole graph
1023 # each time.
1024 self._process_lookup = {}
1025
1026 class Tracer(object):
1027 """During it's lifetime, the tracing subsystem is enabled."""
1028 def __init__(self, logname):
1029 self._logname = logname
maruel@chromium.org808e3f22013-05-16 16:42:54 +00001030 self._lock = threading.RLock()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001031 self._traces = []
1032 self._initialized = True
maruel@chromium.org808e3f22013-05-16 16:42:54 +00001033 self._script = None
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001034
1035 def trace(self, cmd, cwd, tracename, output):
1036 """Runs the OS-specific trace program on an executable.
1037
1038 Arguments:
1039 - cmd: The command (a list) to run.
1040 - cwd: Current directory to start the child process in.
1041 - tracename: Name of the trace in the logname file.
1042 - output: If False, redirects output to PIPEs.
1043
1044 Returns a tuple (resultcode, output) and updates the internal trace
1045 entries.
1046 """
1047 # The implementation adds an item to self._traces.
1048 raise NotImplementedError(self.__class__.__name__)
1049
1050 def close(self, _timeout=None):
1051 """Saves the meta-data in the logname file.
1052
1053 For kernel-based tracing, stops the tracing subsystem.
1054
1055 Must not be used manually when using 'with' construct.
1056 """
1057 with self._lock:
maruel@chromium.org808e3f22013-05-16 16:42:54 +00001058 if not self._initialized:
1059 raise TracingFailure(
1060 'Called %s.close() on an unitialized object' %
1061 self.__class__.__name__,
1062 None, None, None)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001063 try:
maruel@chromium.org808e3f22013-05-16 16:42:54 +00001064 if self._script:
1065 os.remove(self._script)
1066 self._script = None
1067 write_json(self._logname, self._gen_logdata(), False)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001068 finally:
1069 self._initialized = False
1070
1071 def post_process_log(self):
1072 """Post-processes the log so it becomes faster to load afterward.
1073
1074 Must not be used manually when using 'with' construct.
1075 """
1076 assert not self._initialized, 'Must stop tracing first.'
1077
maruel@chromium.org808e3f22013-05-16 16:42:54 +00001078 def _gen_logdata(self):
1079 """Returns the data to be saved in the trace file."""
1080 return {
1081 'traces': self._traces,
1082 }
1083
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001084 def __enter__(self):
1085 """Enables 'with' statement."""
1086 return self
1087
1088 def __exit__(self, exc_type, exc_value, traceback):
1089 """Enables 'with' statement."""
1090 self.close()
1091 # If an exception was thrown, do not process logs.
1092 if not exc_type:
1093 self.post_process_log()
1094
1095 def get_tracer(self, logname):
1096 """Returns an ApiBase.Tracer instance.
1097
1098 Initializes the tracing subsystem, which is a requirement for kernel-based
1099 tracers. Only one tracer instance should be live at a time!
1100
1101 logname is the filepath to the json file that will contain the meta-data
1102 about the logs.
1103 """
1104 return self.Tracer(logname)
1105
1106 @staticmethod
1107 def clean_trace(logname):
1108 """Deletes an old log."""
1109 raise NotImplementedError()
1110
1111 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001112 def parse_log(cls, logname, blacklist, trace_name):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001113 """Processes trace logs and returns the files opened and the files that do
1114 not exist.
1115
1116 It does not track directories.
1117
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001118 Arguments:
1119 - logname: must be an absolute path.
1120 - blacklist: must be a lambda.
1121 - trace_name: optional trace to read, defaults to reading all traces.
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00001122
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001123 Most of the time, files that do not exist are temporary test files that
1124 should be put in /tmp instead. See http://crbug.com/116251.
1125
1126 Returns a list of dict with keys:
1127 - results: A Results instance.
1128 - trace: The corresponding tracename parameter provided to
1129 get_tracer().trace().
1130 - output: Output gathered during execution, if get_tracer().trace(...,
1131 output=False) was used.
1132 """
1133 raise NotImplementedError(cls.__class__.__name__)
1134
1135
1136class Strace(ApiBase):
1137 """strace implies linux."""
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001138 @staticmethod
1139 def load_filename(filename):
1140 """Parses a filename in a log."""
1141 assert isinstance(filename, str)
1142 out = ''
1143 i = 0
1144 while i < len(filename):
1145 c = filename[i]
1146 if c == '\\':
1147 out += chr(int(filename[i+1:i+4], 8))
1148 i += 4
1149 else:
1150 out += c
1151 i += 1
1152 # TODO(maruel): That's not necessarily true that the current code page is
1153 # utf-8.
1154 return out.decode('utf-8')
1155
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001156 class Context(ApiBase.Context):
1157 """Processes a strace log line and keeps the list of existent and non
1158 existent files accessed.
1159
1160 Ignores directories.
1161
1162 Uses late-binding to processes the cwd of each process. The problem is that
1163 strace generates one log file per process it traced but doesn't give any
1164 information about which process was started when and by who. So we don't
1165 even know which process is the initial one. So process the logs out of
1166 order and use late binding with RelativePath to be able to deduce the
1167 initial directory of each process once all the logs are parsed.
1168 """
1169 class Process(ApiBase.Context.Process):
1170 """Represents the state of a process.
1171
1172 Contains all the information retrieved from the pid-specific log.
1173 """
1174 # Function names are using ([a-z_0-9]+)
1175 # This is the most common format. function(args) = result
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001176 RE_HEADER = re.compile(r'^([a-z_0-9]+)\((.*?)\)\s+= (.+)$')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001177 # An interrupted function call, only grab the minimal header.
1178 RE_UNFINISHED = re.compile(r'^([^\(]+)(.*) \<unfinished \.\.\.\>$')
1179 # A resumed function call.
1180 RE_RESUMED = re.compile(r'^<\.\.\. ([^ ]+) resumed> (.+)$')
1181 # A process received a signal.
1182 RE_SIGNAL = re.compile(r'^--- SIG[A-Z]+ .+ ---')
1183 # A process didn't handle a signal. Ignore any junk appearing before,
1184 # because the process was forcibly killed so it won't open any new file.
1185 RE_KILLED = re.compile(
1186 r'^.*\+\+\+ killed by ([A-Z]+)( \(core dumped\))? \+\+\+$')
1187 # The process has exited.
1188 RE_PROCESS_EXITED = re.compile(r'^\+\+\+ exited with (\d+) \+\+\+')
1189 # A call was canceled. Ignore any prefix.
1190 RE_UNAVAILABLE = re.compile(r'^.*\)\s*= \? <unavailable>$')
1191 # Happens when strace fails to even get the function name.
1192 UNNAMED_FUNCTION = '????'
1193
1194 # Corner-case in python, a class member function decorator must not be
1195 # @staticmethod.
1196 def parse_args(regexp, expect_zero): # pylint: disable=E0213
1197 """Automatically convert the str 'args' into a list of processed
1198 arguments.
1199
1200 Arguments:
1201 - regexp is used to parse args.
1202 - expect_zero: one of True, False or None.
1203 - True: will check for result.startswith('0') first and will ignore
1204 the trace line completely otherwise. This is important because for
1205 many functions, the regexp will not process if the call failed.
1206 - False: will check for not result.startswith(('?', '-1')) for the
1207 same reason than with True.
1208 - None: ignore result.
1209 """
1210 def meta_hook(function):
1211 assert function.__name__.startswith('handle_')
1212 def hook(self, args, result):
1213 if expect_zero is True and not result.startswith('0'):
1214 return
1215 if expect_zero is False and result.startswith(('?', '-1')):
1216 return
1217 match = re.match(regexp, args)
1218 if not match:
1219 raise TracingFailure(
1220 'Failed to parse %s(%s) = %s' %
1221 (function.__name__[len('handle_'):], args, result),
1222 None, None, None)
1223 return function(self, match.groups(), result)
1224 return hook
1225 return meta_hook
1226
1227 class RelativePath(object):
1228 """A late-bound relative path."""
1229 def __init__(self, parent, value):
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001230 assert_is_renderable(parent)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001231 self.parent = parent
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001232 assert isinstance(value, (None.__class__, str)), repr(value)
1233 self.value = Strace.load_filename(value) if value else value
1234 if self.value:
1235 assert '\\' not in self.value, value
1236 assert '\\' not in self.value, (repr(value), repr(self.value))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001237
1238 def render(self):
1239 """Returns the current directory this instance is representing.
1240
1241 This function is used to return the late-bound value.
1242 """
1243 if self.value and self.value.startswith(u'/'):
1244 # An absolute path.
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001245 # TODO(maruel): This is wrong, we can't assert it is utf-8.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001246 return self.value
1247 parent = self.parent.render() if self.parent else u'<None>'
1248 if self.value:
1249 return os.path.normpath(os.path.join(parent, self.value))
1250 return parent
1251
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001252 def __init__(self, root, pid):
1253 """Keeps enough information to be able to guess the original process
1254 root.
1255
1256 strace doesn't store which process was the initial process. So more
1257 information needs to be kept so the graph can be reconstructed from the
1258 flat map.
1259 """
1260 logging.info('%s(%d)' % (self.__class__.__name__, pid))
1261 super(Strace.Context.Process, self).__init__(root.blacklist, pid, None)
1262 assert isinstance(root, ApiBase.Context)
1263 self._root = weakref.ref(root)
1264 # The dict key is the function name of the pending call, like 'open'
1265 # or 'execve'.
1266 self._pending_calls = {}
1267 self._line_number = 0
1268 # Current directory when the process started.
1269 self.initial_cwd = self.RelativePath(self._root(), None)
1270 self.parentid = None
maruel@chromium.org9f72e0f2012-10-18 14:48:49 +00001271 self._done = False
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001272
1273 def get_cwd(self):
1274 """Returns the best known value of cwd."""
1275 return self.cwd or self.initial_cwd
1276
1277 def render(self):
1278 """Returns the string value of the RelativePath() object.
1279
1280 Used by RelativePath. Returns the initial directory and not the
1281 current one since the current directory 'cwd' validity is time-limited.
1282
1283 The validity is only guaranteed once all the logs are processed.
1284 """
1285 return self.initial_cwd.render()
1286
1287 def on_line(self, line):
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001288 assert isinstance(line, str)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001289 self._line_number += 1
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001290 try:
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001291 if self._done:
1292 raise TracingFailure(
1293 'Found a trace for a terminated process or corrupted log',
1294 None, None, None)
1295
1296 if self.RE_SIGNAL.match(line):
1297 # Ignore signals.
1298 return
1299
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001300 match = self.RE_KILLED.match(line)
1301 if match:
1302 # Converts a '+++ killed by Foo +++' trace into an exit_group().
1303 self.handle_exit_group(match.group(1), None)
1304 return
1305
1306 match = self.RE_PROCESS_EXITED.match(line)
1307 if match:
1308 # Converts a '+++ exited with 1 +++' trace into an exit_group()
1309 self.handle_exit_group(match.group(1), None)
1310 return
1311
1312 match = self.RE_UNFINISHED.match(line)
1313 if match:
1314 if match.group(1) in self._pending_calls:
1315 raise TracingFailure(
1316 'Found two unfinished calls for the same function',
1317 None, None, None,
1318 self._pending_calls)
1319 self._pending_calls[match.group(1)] = (
1320 match.group(1) + match.group(2))
1321 return
1322
1323 match = self.RE_UNAVAILABLE.match(line)
1324 if match:
1325 # This usually means a process was killed and a pending call was
1326 # canceled.
1327 # TODO(maruel): Look up the last exit_group() trace just above and
1328 # make sure any self._pending_calls[anything] is properly flushed.
1329 return
1330
1331 match = self.RE_RESUMED.match(line)
1332 if match:
1333 if match.group(1) not in self._pending_calls:
1334 raise TracingFailure(
1335 'Found a resumed call that was not logged as unfinished',
1336 None, None, None,
1337 self._pending_calls)
1338 pending = self._pending_calls.pop(match.group(1))
1339 # Reconstruct the line.
1340 line = pending + match.group(2)
1341
1342 match = self.RE_HEADER.match(line)
1343 if not match:
maruel@chromium.orgbcfc9e12013-03-19 18:47:17 +00001344 # The line is corrupted. It happens occasionally when a process is
1345 # killed forcibly with activity going on. Assume the process died.
1346 # No other line can be processed afterward.
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001347 logging.debug('%d is done: %s', self.pid, line)
maruel@chromium.orgbcfc9e12013-03-19 18:47:17 +00001348 self._done = True
1349 return
1350
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001351 if match.group(1) == self.UNNAMED_FUNCTION:
1352 return
1353
1354 # It's a valid line, handle it.
1355 handler = getattr(self, 'handle_%s' % match.group(1), None)
1356 if not handler:
1357 self._handle_unknown(match.group(1), match.group(2), match.group(3))
1358 return handler(match.group(2), match.group(3))
1359 except TracingFailure, e:
1360 # Hack in the values since the handler could be a static function.
1361 e.pid = self.pid
1362 e.line = line
1363 e.line_number = self._line_number
1364 # Re-raise the modified exception.
1365 raise
1366 except (KeyError, NotImplementedError, ValueError), e:
1367 raise TracingFailure(
1368 'Trace generated a %s exception: %s' % (
1369 e.__class__.__name__, str(e)),
1370 self.pid,
1371 self._line_number,
1372 line,
1373 e)
1374
1375 @parse_args(r'^\"(.+?)\", [FKORWX_|]+$', True)
1376 def handle_access(self, args, _result):
1377 self._handle_file(args[0], True)
1378
1379 @parse_args(r'^\"(.+?)\"$', True)
1380 def handle_chdir(self, args, _result):
1381 """Updates cwd."""
1382 self.cwd = self.RelativePath(self, args[0])
1383 logging.debug('handle_chdir(%d, %s)' % (self.pid, self.cwd))
1384
maruel@chromium.org55550722013-05-28 14:43:14 +00001385 def handle_chown(self, _args, result):
1386 pass
1387
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001388 def handle_clone(self, _args, result):
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001389 self._handling_forking('clone', result)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001390
1391 def handle_close(self, _args, _result):
1392 pass
1393
1394 def handle_chmod(self, _args, _result):
1395 pass
1396
1397 def handle_creat(self, _args, _result):
1398 # Ignore files created, since they didn't need to exist.
1399 pass
1400
1401 @parse_args(r'^\"(.+?)\", \[(.+)\], \[\/\* \d+ vars? \*\/\]$', True)
1402 def handle_execve(self, args, _result):
1403 # Even if in practice execve() doesn't returns when it succeeds, strace
1404 # still prints '0' as the result.
1405 filepath = args[0]
1406 self._handle_file(filepath, False)
1407 self.executable = self.RelativePath(self.get_cwd(), filepath)
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001408 try:
1409 self.command = strace_process_quoted_arguments(args[1])
1410 except ValueError as e:
1411 raise TracingFailure(
1412 'Failed to process command line argument:\n%s' % e.args[0],
1413 None, None, None)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001414
1415 def handle_exit_group(self, _args, _result):
1416 """Removes cwd."""
1417 self.cwd = None
1418
csharp@chromium.org11ae0672012-10-30 14:53:17 +00001419 @parse_args(r'^(\d+|AT_FDCWD), \"(.*?)\", ([A-Z\_\|]+)(|, \d+)$', True)
1420 def handle_faccessat(self, args, _results):
1421 if args[0] == 'AT_FDCWD':
1422 self._handle_file(args[1], True)
1423 else:
1424 raise Exception('Relative faccess not implemented.')
1425
maruel@chromium.org0781f322013-05-28 19:45:49 +00001426 def handle_fallocate(self, _args, result):
1427 pass
1428
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001429 def handle_fork(self, args, result):
1430 self._handle_unknown('fork', args, result)
1431
maruel@chromium.org9f72e0f2012-10-18 14:48:49 +00001432 def handle_futex(self, _args, _result):
1433 pass
1434
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001435 def handle_getcwd(self, _args, _result):
1436 pass
1437
1438 @parse_args(r'^\"(.+?)\", \"(.+?)\"$', True)
1439 def handle_link(self, args, _result):
1440 self._handle_file(args[0], False)
1441 self._handle_file(args[1], False)
1442
1443 @parse_args(r'\"(.+?)\", \{.+?, \.\.\.\}', True)
1444 def handle_lstat(self, args, _result):
1445 self._handle_file(args[0], True)
1446
1447 def handle_mkdir(self, _args, _result):
1448 pass
1449
1450 @parse_args(r'^\"(.*?)\", ([A-Z\_\|]+)(|, \d+)$', False)
1451 def handle_open(self, args, _result):
1452 if 'O_DIRECTORY' in args[1]:
1453 return
1454 self._handle_file(args[0], False)
1455
1456 @parse_args(r'^(\d+|AT_FDCWD), \"(.*?)\", ([A-Z\_\|]+)(|, \d+)$', False)
1457 def handle_openat(self, args, _result):
1458 if 'O_DIRECTORY' in args[2]:
1459 return
csharp@chromium.org11ae0672012-10-30 14:53:17 +00001460 if args[0] == 'AT_FDCWD':
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001461 self._handle_file(args[1], False)
1462 else:
1463 # TODO(maruel): Implement relative open if necessary instead of the
1464 # AT_FDCWD flag, let's hope not since this means tracking all active
1465 # directory handles.
1466 raise Exception('Relative open via openat not implemented.')
1467
1468 @parse_args(r'^\"(.+?)\", \".+?\"(\.\.\.)?, \d+$', False)
1469 def handle_readlink(self, args, _result):
1470 self._handle_file(args[0], False)
1471
1472 @parse_args(r'^\"(.+?)\", \"(.+?)\"$', True)
1473 def handle_rename(self, args, _result):
1474 self._handle_file(args[0], False)
1475 self._handle_file(args[1], False)
1476
1477 def handle_rmdir(self, _args, _result):
1478 pass
1479
1480 def handle_setxattr(self, _args, _result):
1481 pass
1482
1483 @parse_args(r'\"(.+?)\", \{.+?, \.\.\.\}', True)
1484 def handle_stat(self, args, _result):
1485 self._handle_file(args[0], True)
1486
1487 def handle_symlink(self, _args, _result):
1488 pass
1489
csharp@chromium.orgfa672922012-11-02 17:43:31 +00001490 @parse_args(r'^\"(.+?)\", \d+', True)
1491 def handle_truncate(self, args, _result):
1492 self._handle_file(args[0], False)
1493
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001494 def handle_unlink(self, _args, _result):
1495 # In theory, the file had to be created anyway.
1496 pass
1497
csharp@chromium.org11ae0672012-10-30 14:53:17 +00001498 def handle_unlinkat(self, _args, _result):
1499 # In theory, the file had to be created anyway.
1500 pass
1501
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001502 def handle_statfs(self, _args, _result):
1503 pass
1504
csharp@chromium.org11ae0672012-10-30 14:53:17 +00001505 def handle_utimensat(self, _args, _result):
1506 pass
1507
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001508 def handle_vfork(self, _args, result):
1509 self._handling_forking('vfork', result)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001510
1511 @staticmethod
1512 def _handle_unknown(function, args, result):
1513 raise TracingFailure(
1514 'Unexpected/unimplemented trace %s(%s)= %s' %
1515 (function, args, result),
1516 None, None, None)
1517
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001518 def _handling_forking(self, name, result):
1519 """Transfers cwd."""
1520 if result.startswith(('?', '-1')):
1521 # The call failed.
1522 return
1523 # Update the other process right away.
1524 childpid = int(result)
1525 child = self._root().get_or_set_proc(childpid)
1526 if child.parentid is not None or childpid in self.children:
1527 raise TracingFailure(
1528 'Found internal inconsitency in process lifetime detection '
1529 'during a %s() call' % name,
1530 None, None, None)
1531
1532 # Copy the cwd object.
1533 child.initial_cwd = self.get_cwd()
1534 child.parentid = self.pid
1535 # It is necessary because the logs are processed out of order.
1536 self.children.append(child)
1537
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001538 def _handle_file(self, filepath, touch_only):
1539 filepath = self.RelativePath(self.get_cwd(), filepath)
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001540 #assert not touch_only, render(filepath)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001541 self.add_file(filepath, touch_only)
1542
1543 def __init__(self, blacklist, initial_cwd):
1544 super(Strace.Context, self).__init__(blacklist)
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001545 assert_is_renderable(initial_cwd)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001546 self.initial_cwd = initial_cwd
1547
1548 def render(self):
1549 """Returns the string value of the initial cwd of the root process.
1550
1551 Used by RelativePath.
1552 """
1553 return self.initial_cwd
1554
1555 def on_line(self, pid, line):
1556 """Transfers control into the Process.on_line() function."""
1557 self.get_or_set_proc(pid).on_line(line.strip())
1558
1559 def to_results(self):
1560 """Finds back the root process and verify consistency."""
1561 # TODO(maruel): Absolutely unecessary, fix me.
1562 root = [p for p in self._process_lookup.itervalues() if not p.parentid]
1563 if len(root) != 1:
1564 raise TracingFailure(
1565 'Found internal inconsitency in process lifetime detection '
1566 'while finding the root process',
1567 None,
1568 None,
1569 None,
1570 sorted(p.pid for p in root))
1571 self.root_process = root[0]
1572 process = self.root_process.to_results_process()
1573 if sorted(self._process_lookup) != sorted(p.pid for p in process.all):
1574 raise TracingFailure(
1575 'Found internal inconsitency in process lifetime detection '
1576 'while looking for len(tree) == len(list)',
1577 None,
1578 None,
1579 None,
1580 sorted(self._process_lookup),
1581 sorted(p.pid for p in process.all))
1582 return Results(process)
1583
1584 def get_or_set_proc(self, pid):
1585 """Returns the Context.Process instance for this pid or creates a new one.
1586 """
1587 if not pid or not isinstance(pid, int):
1588 raise TracingFailure(
1589 'Unpexpected value for pid: %r' % pid,
1590 pid,
1591 None,
1592 None,
1593 pid)
1594 if pid not in self._process_lookup:
1595 self._process_lookup[pid] = self.Process(self, pid)
1596 return self._process_lookup[pid]
1597
1598 @classmethod
1599 def traces(cls):
1600 """Returns the list of all handled traces to pass this as an argument to
1601 strace.
1602 """
1603 prefix = 'handle_'
1604 return [i[len(prefix):] for i in dir(cls.Process) if i.startswith(prefix)]
1605
1606 class Tracer(ApiBase.Tracer):
1607 MAX_LEN = 256
1608
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00001609 def __init__(self, logname, use_sudo):
1610 super(Strace.Tracer, self).__init__(logname)
1611 self.use_sudo = use_sudo
1612
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001613 def trace(self, cmd, cwd, tracename, output):
1614 """Runs strace on an executable."""
1615 logging.info('trace(%s, %s, %s, %s)' % (cmd, cwd, tracename, output))
1616 assert os.path.isabs(cmd[0]), cmd[0]
1617 assert os.path.isabs(cwd), cwd
1618 assert os.path.normpath(cwd) == cwd, cwd
1619 with self._lock:
1620 if not self._initialized:
1621 raise TracingFailure(
1622 'Called Tracer.trace() on an unitialized object',
1623 None, None, None, tracename)
1624 assert tracename not in (i['trace'] for i in self._traces)
1625 stdout = stderr = None
1626 if output:
1627 stdout = subprocess.PIPE
1628 stderr = subprocess.STDOUT
1629 # Ensure all file related APIs are hooked.
1630 traces = ','.join(Strace.Context.traces() + ['file'])
1631 trace_cmd = [
1632 'strace',
maruel@chromium.orga5cfdc32013-05-28 14:46:15 +00001633 # Each child process has its own trace file. It is necessary because
1634 # strace may generate corrupted log file if multiple processes are
1635 # heavily doing syscalls simultaneously.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001636 '-ff',
maruel@chromium.orga5cfdc32013-05-28 14:46:15 +00001637 # Reduce whitespace usage.
1638 '-a1',
1639 # hex encode non-ascii strings.
1640 # TODO(maruel): '-x',
1641 # TODO(maruel): '-ttt',
1642 # Signals are unnecessary noise here. Note the parser can cope with them
1643 # but reduce the unnecessary output.
1644 '-esignal=none',
1645 # Print as much data as wanted.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001646 '-s', '%d' % self.MAX_LEN,
1647 '-e', 'trace=%s' % traces,
1648 '-o', self._logname + '.' + tracename,
1649 ]
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00001650 if self.use_sudo is True:
1651 trace_cmd.insert(0, 'sudo')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001652 child = subprocess.Popen(
1653 trace_cmd + cmd,
1654 cwd=cwd,
1655 stdin=subprocess.PIPE,
1656 stdout=stdout,
1657 stderr=stderr)
1658 out = child.communicate()[0]
1659 # TODO(maruel): Walk the logs and figure out the root process would
1660 # simplify parsing the logs a *lot*.
1661 with self._lock:
1662 assert tracename not in (i['trace'] for i in self._traces)
1663 self._traces.append(
1664 {
1665 'cmd': cmd,
1666 'cwd': cwd,
maruel@chromium.org964ddde2012-10-29 18:13:46 +00001667 'output': out,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001668 # The pid of strace process, not very useful.
1669 'pid': child.pid,
1670 'trace': tracename,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001671 })
1672 return child.returncode, out
1673
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00001674 def __init__(self, use_sudo=None):
1675 super(Strace, self).__init__()
1676 self.use_sudo = use_sudo
1677
1678 def get_tracer(self, logname):
1679 return self.Tracer(logname, self.use_sudo)
1680
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001681 @staticmethod
1682 def clean_trace(logname):
1683 if os.path.isfile(logname):
1684 os.remove(logname)
1685 # Also delete any pid specific file from previous traces.
1686 for i in glob.iglob(logname + '.*'):
1687 if i.rsplit('.', 1)[1].isdigit():
1688 os.remove(i)
1689
1690 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001691 def parse_log(cls, logname, blacklist, trace_name):
1692 logging.info('parse_log(%s, ..., %s)', logname, trace_name)
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00001693 assert os.path.isabs(logname)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001694 data = read_json(logname)
1695 out = []
1696 for item in data['traces']:
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001697 if trace_name and item['trace'] != trace_name:
1698 continue
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001699 result = {
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001700 'output': item['output'],
maruel@chromium.org964ddde2012-10-29 18:13:46 +00001701 'trace': item['trace'],
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001702 }
1703 try:
1704 context = cls.Context(blacklist, item['cwd'])
1705 for pidfile in glob.iglob('%s.%s.*' % (logname, item['trace'])):
1706 pid = pidfile.rsplit('.', 1)[1]
1707 if pid.isdigit():
1708 pid = int(pid)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001709 for line in open(pidfile, 'rb'):
1710 context.on_line(pid, line)
1711 result['results'] = context.to_results()
maruel@chromium.org424f5692012-10-15 15:42:38 +00001712 except TracingFailure:
1713 result['exception'] = sys.exc_info()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001714 out.append(result)
1715 return out
1716
1717
1718class Dtrace(ApiBase):
1719 """Uses DTrace framework through dtrace. Requires root access.
1720
1721 Implies Mac OSX.
1722
1723 dtruss can't be used because it has compatibility issues with python.
1724
1725 Also, the pid->cwd handling needs to be done manually since OSX has no way to
1726 get the absolute path of the 'cwd' dtrace variable from the probe.
1727
1728 Also, OSX doesn't populate curpsinfo->pr_psargs properly, see
1729 https://discussions.apple.com/thread/1980539. So resort to handling execve()
1730 manually.
1731
1732 errno is not printed in the log since this implementation currently only cares
1733 about files that were successfully opened.
1734 """
1735 class Context(ApiBase.Context):
1736 # Format: index pid function(args)
1737 RE_HEADER = re.compile(r'^\d+ (\d+) ([a-zA-Z_\-]+)\((.*?)\)$')
1738
1739 # Arguments parsing.
1740 RE_DTRACE_BEGIN = re.compile(r'^\"(.+?)\"$')
1741 RE_CHDIR = re.compile(r'^\"(.+?)\"$')
1742 RE_EXECVE = re.compile(r'^\"(.+?)\", \[(\d+), (.+)\]$')
1743 RE_OPEN = re.compile(r'^\"(.+?)\", (0x[0-9a-z]+), (0x[0-9a-z]+)$')
1744 RE_PROC_START = re.compile(r'^(\d+), \"(.+?)\", (\d+)$')
1745 RE_RENAME = re.compile(r'^\"(.+?)\", \"(.+?)\"$')
1746
1747 O_DIRECTORY = 0x100000
1748
1749 class Process(ApiBase.Context.Process):
1750 def __init__(self, *args):
1751 super(Dtrace.Context.Process, self).__init__(*args)
1752 self.cwd = self.initial_cwd
1753
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001754 def __init__(self, blacklist, thunk_pid, initial_cwd):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001755 logging.info(
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001756 '%s(%d, %s)' % (self.__class__.__name__, thunk_pid, initial_cwd))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001757 super(Dtrace.Context, self).__init__(blacklist)
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001758 assert isinstance(initial_cwd, unicode), initial_cwd
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001759 # Process ID of the temporary script created by create_thunk().
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001760 self._thunk_pid = thunk_pid
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001761 self._initial_cwd = initial_cwd
1762 self._line_number = 0
1763
1764 def on_line(self, line):
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001765 assert isinstance(line, unicode), line
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001766 self._line_number += 1
1767 match = self.RE_HEADER.match(line)
1768 if not match:
1769 raise TracingFailure(
1770 'Found malformed line: %s' % line,
1771 None,
1772 self._line_number,
1773 line)
1774 fn = getattr(
1775 self,
1776 'handle_%s' % match.group(2).replace('-', '_'),
1777 self._handle_ignored)
1778 # It is guaranteed to succeed because of the regexp. Or at least I thought
1779 # it would.
1780 pid = int(match.group(1))
1781 try:
1782 return fn(pid, match.group(3))
1783 except TracingFailure, e:
1784 # Hack in the values since the handler could be a static function.
1785 e.pid = pid
1786 e.line = line
1787 e.line_number = self._line_number
1788 # Re-raise the modified exception.
1789 raise
1790 except (KeyError, NotImplementedError, ValueError), e:
1791 raise TracingFailure(
1792 'Trace generated a %s exception: %s' % (
1793 e.__class__.__name__, str(e)),
1794 pid,
1795 self._line_number,
1796 line,
1797 e)
1798
1799 def to_results(self):
1800 process = self.root_process.to_results_process()
1801 # Internal concistency check.
1802 if sorted(self._process_lookup) != sorted(p.pid for p in process.all):
1803 raise TracingFailure(
1804 'Found internal inconsitency in process lifetime detection '
1805 'while looking for len(tree) == len(list)',
1806 None,
1807 None,
1808 None,
1809 sorted(self._process_lookup),
1810 sorted(p.pid for p in process.all))
1811 return Results(process)
1812
1813 def handle_dtrace_BEGIN(self, _pid, args):
1814 if not self.RE_DTRACE_BEGIN.match(args):
1815 raise TracingFailure(
1816 'Found internal inconsitency in dtrace_BEGIN log line',
1817 None, None, None)
1818
1819 def handle_proc_start(self, pid, args):
1820 """Transfers cwd.
1821
1822 The dtrace script already takes care of only tracing the processes that
1823 are child of the traced processes so there is no need to verify the
1824 process hierarchy.
1825 """
1826 if pid in self._process_lookup:
1827 raise TracingFailure(
1828 'Found internal inconsitency in proc_start: %d started two times' %
1829 pid,
1830 None, None, None)
1831 match = self.RE_PROC_START.match(args)
1832 if not match:
1833 raise TracingFailure(
1834 'Failed to parse arguments: %s' % args,
1835 None, None, None)
1836 ppid = int(match.group(1))
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001837 if ppid == self._thunk_pid and not self.root_process:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001838 proc = self.root_process = self.Process(
1839 self.blacklist, pid, self._initial_cwd)
1840 elif ppid in self._process_lookup:
1841 proc = self.Process(self.blacklist, pid, self._process_lookup[ppid].cwd)
1842 self._process_lookup[ppid].children.append(proc)
1843 else:
1844 # Another process tree, ignore.
1845 return
1846 self._process_lookup[pid] = proc
1847 logging.debug(
1848 'New child: %s -> %d cwd:%s' %
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001849 (ppid, pid, render(proc.initial_cwd)))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001850
1851 def handle_proc_exit(self, pid, _args):
1852 """Removes cwd."""
1853 if pid in self._process_lookup:
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001854 # self._thunk_pid is not traced itself and other traces run neither.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001855 self._process_lookup[pid].cwd = None
1856
1857 def handle_execve(self, pid, args):
1858 """Sets the process' executable.
1859
1860 TODO(maruel): Read command line arguments. See
1861 https://discussions.apple.com/thread/1980539 for an example.
1862 https://gist.github.com/1242279
1863
1864 Will have to put the answer at http://stackoverflow.com/questions/7556249.
1865 :)
1866 """
1867 if not pid in self._process_lookup:
1868 # Another process tree, ignore.
1869 return
1870 match = self.RE_EXECVE.match(args)
1871 if not match:
1872 raise TracingFailure(
1873 'Failed to parse arguments: %r' % args,
1874 None, None, None)
1875 proc = self._process_lookup[pid]
1876 proc.executable = match.group(1)
1877 proc.command = self.process_escaped_arguments(match.group(3))
1878 if int(match.group(2)) != len(proc.command):
1879 raise TracingFailure(
1880 'Failed to parse execve() arguments: %s' % args,
1881 None, None, None)
1882
1883 def handle_chdir(self, pid, args):
1884 """Updates cwd."""
1885 if pid not in self._process_lookup:
1886 # Another process tree, ignore.
1887 return
1888 cwd = self.RE_CHDIR.match(args).group(1)
1889 if not cwd.startswith('/'):
1890 cwd2 = os.path.join(self._process_lookup[pid].cwd, cwd)
1891 logging.debug('handle_chdir(%d, %s) -> %s' % (pid, cwd, cwd2))
1892 else:
1893 logging.debug('handle_chdir(%d, %s)' % (pid, cwd))
1894 cwd2 = cwd
1895 self._process_lookup[pid].cwd = cwd2
1896
1897 def handle_open_nocancel(self, pid, args):
1898 """Redirects to handle_open()."""
1899 return self.handle_open(pid, args)
1900
1901 def handle_open(self, pid, args):
1902 if pid not in self._process_lookup:
1903 # Another process tree, ignore.
1904 return
1905 match = self.RE_OPEN.match(args)
1906 if not match:
1907 raise TracingFailure(
1908 'Failed to parse arguments: %s' % args,
1909 None, None, None)
1910 flag = int(match.group(2), 16)
1911 if self.O_DIRECTORY & flag == self.O_DIRECTORY:
1912 # Ignore directories.
1913 return
1914 self._handle_file(pid, match.group(1))
1915
1916 def handle_rename(self, pid, args):
1917 if pid not in self._process_lookup:
1918 # Another process tree, ignore.
1919 return
1920 match = self.RE_RENAME.match(args)
1921 if not match:
1922 raise TracingFailure(
1923 'Failed to parse arguments: %s' % args,
1924 None, None, None)
1925 self._handle_file(pid, match.group(1))
1926 self._handle_file(pid, match.group(2))
1927
1928 def _handle_file(self, pid, filepath):
1929 if not filepath.startswith('/'):
1930 filepath = os.path.join(self._process_lookup[pid].cwd, filepath)
1931 # We can get '..' in the path.
1932 filepath = os.path.normpath(filepath)
1933 # Sadly, still need to filter out directories here;
1934 # saw open_nocancel(".", 0, 0) = 0 lines.
1935 if os.path.isdir(filepath):
1936 return
1937 self._process_lookup[pid].add_file(filepath, False)
1938
1939 def handle_ftruncate(self, pid, args):
1940 """Just used as a signal to kill dtrace, ignoring."""
1941 pass
1942
1943 @staticmethod
1944 def _handle_ignored(pid, args):
1945 """Is called for all the event traces that are not handled."""
1946 raise NotImplementedError('Please implement me')
1947
1948 @staticmethod
1949 def process_escaped_arguments(text):
1950 """Extracts escaped arguments on a string and return the arguments as a
1951 list.
1952
1953 Implemented as an automaton.
1954
1955 Example:
1956 With text = '\\001python2.7\\001-c\\001print(\\"hi\\")\\0', the
1957 function will return ['python2.7', '-c', 'print("hi")]
1958 """
1959 if not text.endswith('\\0'):
1960 raise ValueError('String is not null terminated: %r' % text, text)
1961 text = text[:-2]
1962
1963 def unescape(x):
1964 """Replaces '\\' with '\' and '\?' (where ? is anything) with ?."""
1965 out = []
1966 escaped = False
1967 for i in x:
1968 if i == '\\' and not escaped:
1969 escaped = True
1970 continue
1971 escaped = False
1972 out.append(i)
1973 return ''.join(out)
1974
1975 return [unescape(i) for i in text.split('\\001')]
1976
1977 class Tracer(ApiBase.Tracer):
1978 # pylint: disable=C0301
1979 #
1980 # To understand the following code, you'll want to take a look at:
1981 # http://developers.sun.com/solaris/articles/dtrace_quickref/dtrace_quickref.html
1982 # https://wikis.oracle.com/display/DTrace/Variables
1983 # http://docs.oracle.com/cd/E19205-01/820-4221/
1984 #
1985 # 0. Dump all the valid probes into a text file. It is important, you
1986 # want to redirect into a file and you don't want to constantly 'sudo'.
1987 # $ sudo dtrace -l > probes.txt
1988 #
1989 # 1. Count the number of probes:
1990 # $ wc -l probes.txt
1991 # 81823 # On OSX 10.7, including 1 header line.
1992 #
1993 # 2. List providers, intentionally skipping all the 'syspolicy10925' and the
1994 # likes and skipping the header with NR>1:
1995 # $ awk 'NR>1 { print $2 }' probes.txt | sort | uniq | grep -v '[[:digit:]]'
1996 # dtrace
1997 # fbt
1998 # io
1999 # ip
2000 # lockstat
2001 # mach_trap
2002 # proc
2003 # profile
2004 # sched
2005 # syscall
2006 # tcp
2007 # vminfo
2008 #
2009 # 3. List of valid probes:
2010 # $ grep syscall probes.txt | less
2011 # or use dtrace directly:
2012 # $ sudo dtrace -l -P syscall | less
2013 #
2014 # trackedpid is an associative array where its value can be 0, 1 or 2.
2015 # 0 is for untracked processes and is the default value for items not
2016 # in the associative array.
2017 # 1 is for tracked processes.
2018 # 2 is for the script created by create_thunk() only. It is not tracked
2019 # itself but all its decendants are.
2020 #
2021 # The script will kill itself only once waiting_to_die == 1 and
2022 # current_processes == 0, so that both getlogin() was called and that
2023 # all traced processes exited.
2024 #
2025 # TODO(maruel): Use cacheable predicates. See
2026 # https://wikis.oracle.com/display/DTrace/Performance+Considerations
2027 D_CODE = """
2028 dtrace:::BEGIN {
2029 waiting_to_die = 0;
2030 current_processes = 0;
2031 logindex = 0;
2032 printf("%d %d %s_%s(\\"%s\\")\\n",
2033 logindex, PID, probeprov, probename, SCRIPT);
2034 logindex++;
2035 }
2036
2037 proc:::start /trackedpid[ppid]/ {
2038 trackedpid[pid] = 1;
2039 current_processes += 1;
2040 printf("%d %d %s_%s(%d, \\"%s\\", %d)\\n",
2041 logindex, pid, probeprov, probename,
2042 ppid,
2043 execname,
2044 current_processes);
2045 logindex++;
2046 }
2047 /* Should use SCRIPT but there is no access to this variable at that
2048 * point. */
2049 proc:::start /ppid == PID && execname == "Python"/ {
2050 trackedpid[pid] = 2;
2051 current_processes += 1;
2052 printf("%d %d %s_%s(%d, \\"%s\\", %d)\\n",
2053 logindex, pid, probeprov, probename,
2054 ppid,
2055 execname,
2056 current_processes);
2057 logindex++;
2058 }
2059 proc:::exit /trackedpid[pid] &&
2060 current_processes == 1 &&
2061 waiting_to_die == 1/ {
2062 trackedpid[pid] = 0;
2063 current_processes -= 1;
2064 printf("%d %d %s_%s(%d)\\n",
2065 logindex, pid, probeprov, probename,
2066 current_processes);
2067 logindex++;
2068 exit(0);
2069 }
2070 proc:::exit /trackedpid[pid]/ {
2071 trackedpid[pid] = 0;
2072 current_processes -= 1;
2073 printf("%d %d %s_%s(%d)\\n",
2074 logindex, pid, probeprov, probename,
2075 current_processes);
2076 logindex++;
2077 }
2078
2079 /* Use an arcane function to detect when we need to die */
2080 syscall::ftruncate:entry /pid == PID && arg0 == FILE_ID/ {
2081 waiting_to_die = 1;
2082 printf("%d %d %s()\\n", logindex, pid, probefunc);
2083 logindex++;
2084 }
2085 syscall::ftruncate:entry /
2086 pid == PID && arg0 == FILE_ID && current_processes == 0/ {
2087 exit(0);
2088 }
2089
2090 syscall::open*:entry /trackedpid[pid] == 1/ {
2091 self->open_arg0 = arg0;
2092 self->open_arg1 = arg1;
2093 self->open_arg2 = arg2;
2094 }
2095 syscall::open*:return /trackedpid[pid] == 1 && errno == 0/ {
2096 this->open_arg0 = copyinstr(self->open_arg0);
2097 printf("%d %d %s(\\"%s\\", 0x%x, 0x%x)\\n",
2098 logindex, pid, probefunc,
2099 this->open_arg0,
2100 self->open_arg1,
2101 self->open_arg2);
2102 logindex++;
2103 this->open_arg0 = 0;
2104 }
2105 syscall::open*:return /trackedpid[pid] == 1/ {
2106 self->open_arg0 = 0;
2107 self->open_arg1 = 0;
2108 self->open_arg2 = 0;
2109 }
2110
2111 syscall::rename:entry /trackedpid[pid] == 1/ {
2112 self->rename_arg0 = arg0;
2113 self->rename_arg1 = arg1;
2114 }
2115 syscall::rename:return /trackedpid[pid] == 1 && errno == 0/ {
2116 this->rename_arg0 = copyinstr(self->rename_arg0);
2117 this->rename_arg1 = copyinstr(self->rename_arg1);
2118 printf("%d %d %s(\\"%s\\", \\"%s\\")\\n",
2119 logindex, pid, probefunc,
2120 this->rename_arg0,
2121 this->rename_arg1);
2122 logindex++;
2123 this->rename_arg0 = 0;
2124 this->rename_arg1 = 0;
2125 }
2126 syscall::rename:return /trackedpid[pid] == 1/ {
2127 self->rename_arg0 = 0;
2128 self->rename_arg1 = 0;
2129 }
2130
2131 /* Track chdir, it's painful because it is only receiving relative path.
2132 */
2133 syscall::chdir:entry /trackedpid[pid] == 1/ {
2134 self->chdir_arg0 = arg0;
2135 }
2136 syscall::chdir:return /trackedpid[pid] == 1 && errno == 0/ {
2137 this->chdir_arg0 = copyinstr(self->chdir_arg0);
2138 printf("%d %d %s(\\"%s\\")\\n",
2139 logindex, pid, probefunc,
2140 this->chdir_arg0);
2141 logindex++;
2142 this->chdir_arg0 = 0;
2143 }
2144 syscall::chdir:return /trackedpid[pid] == 1/ {
2145 self->chdir_arg0 = 0;
2146 }
2147 """
2148
2149 # execve-specific code, tends to throw a lot of exceptions.
2150 D_CODE_EXECVE = """
2151 /* Finally what we care about! */
2152 syscall::exec*:entry /trackedpid[pid]/ {
2153 self->exec_arg0 = copyinstr(arg0);
2154 /* Incrementally probe for a NULL in the argv parameter of execve() to
2155 * figure out argc. */
2156 /* TODO(maruel): Skip the remaining copyin() when a NULL pointer was
2157 * found. */
2158 self->exec_argc = 0;
2159 /* Probe for argc==1 */
2160 this->exec_argv = (user_addr_t*)copyin(
2161 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2162 self->exec_argc = this->exec_argv[self->exec_argc] ?
2163 (self->exec_argc + 1) : self->exec_argc;
2164
2165 /* Probe for argc==2 */
2166 this->exec_argv = (user_addr_t*)copyin(
2167 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2168 self->exec_argc = this->exec_argv[self->exec_argc] ?
2169 (self->exec_argc + 1) : self->exec_argc;
2170
2171 /* Probe for argc==3 */
2172 this->exec_argv = (user_addr_t*)copyin(
2173 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2174 self->exec_argc = this->exec_argv[self->exec_argc] ?
2175 (self->exec_argc + 1) : self->exec_argc;
2176
2177 /* Probe for argc==4 */
2178 this->exec_argv = (user_addr_t*)copyin(
2179 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2180 self->exec_argc = this->exec_argv[self->exec_argc] ?
2181 (self->exec_argc + 1) : self->exec_argc;
2182
2183 /* Copy the inputs strings since there is no guarantee they'll be
2184 * present after the call completed. */
2185 self->exec_argv0 = (self->exec_argc > 0) ?
2186 copyinstr(this->exec_argv[0]) : "";
2187 self->exec_argv1 = (self->exec_argc > 1) ?
2188 copyinstr(this->exec_argv[1]) : "";
2189 self->exec_argv2 = (self->exec_argc > 2) ?
2190 copyinstr(this->exec_argv[2]) : "";
2191 self->exec_argv3 = (self->exec_argc > 3) ?
2192 copyinstr(this->exec_argv[3]) : "";
2193 this->exec_argv = 0;
2194 }
2195 syscall::exec*:return /trackedpid[pid] && errno == 0/ {
2196 /* We need to join strings here, as using multiple printf() would
2197 * cause tearing when multiple threads/processes are traced.
2198 * Since it is impossible to escape a string and join it to another one,
2199 * like sprintf("%s%S", previous, more), use hackery.
2200 * Each of the elements are split with a \\1. \\0 cannot be used because
2201 * it is simply ignored. This will conflict with any program putting a
2202 * \\1 in their execve() string but this should be "rare enough" */
2203 this->args = "";
2204 /* Process exec_argv[0] */
2205 this->args = strjoin(
2206 this->args, (self->exec_argc > 0) ? self->exec_argv0 : "");
2207
2208 /* Process exec_argv[1] */
2209 this->args = strjoin(
2210 this->args, (self->exec_argc > 1) ? "\\1" : "");
2211 this->args = strjoin(
2212 this->args, (self->exec_argc > 1) ? self->exec_argv1 : "");
2213
2214 /* Process exec_argv[2] */
2215 this->args = strjoin(
2216 this->args, (self->exec_argc > 2) ? "\\1" : "");
2217 this->args = strjoin(
2218 this->args, (self->exec_argc > 2) ? self->exec_argv2 : "");
2219
2220 /* Process exec_argv[3] */
2221 this->args = strjoin(
2222 this->args, (self->exec_argc > 3) ? "\\1" : "");
2223 this->args = strjoin(
2224 this->args, (self->exec_argc > 3) ? self->exec_argv3 : "");
2225
2226 /* Prints self->exec_argc to permits verifying the internal
2227 * consistency since this code is quite fishy. */
2228 printf("%d %d %s(\\"%s\\", [%d, %S])\\n",
2229 logindex, pid, probefunc,
2230 self->exec_arg0,
2231 self->exec_argc,
2232 this->args);
2233 logindex++;
2234 this->args = 0;
2235 }
2236 syscall::exec*:return /trackedpid[pid]/ {
2237 self->exec_arg0 = 0;
2238 self->exec_argc = 0;
2239 self->exec_argv0 = 0;
2240 self->exec_argv1 = 0;
2241 self->exec_argv2 = 0;
2242 self->exec_argv3 = 0;
2243 }
2244 """
2245
2246 # Code currently not used.
2247 D_EXTRANEOUS = """
2248 /* This is a good learning experience, since it traces a lot of things
2249 * related to the process and child processes.
2250 * Warning: it generates a gigantic log. For example, tracing
2251 * "data/trace_inputs/child1.py --child" generates a 2mb log and takes
2252 * several minutes to execute.
2253 */
2254 /*
2255 mach_trap::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2256 printf("%d %d %s_%s() = %d\\n",
2257 logindex, pid, probeprov, probefunc, errno);
2258 logindex++;
2259 }
2260 proc::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2261 printf("%d %d %s_%s() = %d\\n",
2262 logindex, pid, probeprov, probefunc, errno);
2263 logindex++;
2264 }
2265 sched::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2266 printf("%d %d %s_%s() = %d\\n",
2267 logindex, pid, probeprov, probefunc, errno);
2268 logindex++;
2269 }
2270 syscall::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2271 printf("%d %d %s_%s() = %d\\n",
2272 logindex, pid, probeprov, probefunc, errno);
2273 logindex++;
2274 }
2275 vminfo::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2276 printf("%d %d %s_%s() = %d\\n",
2277 logindex, pid, probeprov, probefunc, errno);
2278 logindex++;
2279 }
2280 */
2281 /* TODO(maruel): *stat* functions and friends
2282 syscall::access:return,
2283 syscall::chdir:return,
2284 syscall::chflags:return,
2285 syscall::chown:return,
2286 syscall::chroot:return,
2287 syscall::getattrlist:return,
2288 syscall::getxattr:return,
2289 syscall::lchown:return,
2290 syscall::lstat64:return,
2291 syscall::lstat:return,
2292 syscall::mkdir:return,
2293 syscall::pathconf:return,
2294 syscall::readlink:return,
2295 syscall::removexattr:return,
2296 syscall::setxattr:return,
2297 syscall::stat64:return,
2298 syscall::stat:return,
2299 syscall::truncate:return,
2300 syscall::unlink:return,
2301 syscall::utimes:return,
2302 */
2303 """
2304
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00002305 def __init__(self, logname, use_sudo):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002306 """Starts the log collection with dtrace.
2307
2308 Requires root access or chmod 4555 on dtrace. dtrace is asynchronous so
2309 this needs to wait for dtrace to be "warmed up".
2310 """
2311 super(Dtrace.Tracer, self).__init__(logname)
2312 self._script = create_thunk()
2313 # This unique dummy temp file is used to signal the dtrace script that it
2314 # should stop as soon as all the child processes are done. A bit hackish
2315 # but works fine enough.
2316 self._dummy_file_id, self._dummy_file_name = tempfile.mkstemp(
2317 prefix='trace_signal_file')
2318
maruel@chromium.org9b2112a2013-04-05 18:01:02 +00002319 dtrace_path = '/usr/sbin/dtrace'
2320 if not os.path.isfile(dtrace_path):
2321 dtrace_path = 'dtrace'
2322 elif use_sudo is None and (os.stat(dtrace_path).st_mode & stat.S_ISUID):
2323 # No need to sudo. For those following at home, don't do that.
2324 use_sudo = False
2325
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002326 # Note: do not use the -p flag. It's useless if the initial process quits
2327 # too fast, resulting in missing traces from the grand-children. The D
2328 # code manages the dtrace lifetime itself.
2329 trace_cmd = [
maruel@chromium.org9b2112a2013-04-05 18:01:02 +00002330 dtrace_path,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002331 # Use a larger buffer if getting 'out of scratch space' errors.
2332 # Ref: https://wikis.oracle.com/display/DTrace/Options+and+Tunables
2333 '-b', '10m',
2334 '-x', 'dynvarsize=10m',
2335 #'-x', 'dtrace_global_maxsize=1m',
2336 '-x', 'evaltime=exec',
2337 '-o', '/dev/stderr',
2338 '-q',
2339 '-n', self._get_dtrace_code(),
2340 ]
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00002341 if use_sudo is not False:
2342 trace_cmd.insert(0, 'sudo')
2343
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002344 with open(self._logname + '.log', 'wb') as logfile:
2345 self._dtrace = subprocess.Popen(
2346 trace_cmd, stdout=logfile, stderr=subprocess.STDOUT)
2347 logging.debug('Started dtrace pid: %d' % self._dtrace.pid)
2348
2349 # Reads until one line is printed, which signifies dtrace is up and ready.
2350 with open(self._logname + '.log', 'rb') as logfile:
2351 while 'dtrace_BEGIN' not in logfile.readline():
2352 if self._dtrace.poll() is not None:
2353 # Do a busy wait. :/
2354 break
2355 logging.debug('dtrace started')
2356
2357 def _get_dtrace_code(self):
2358 """Setups the D code to implement child process tracking.
2359
2360 Injects the cookie in the script so it knows when to stop.
2361
2362 The script will detect any instance of the script created with
2363 create_thunk() and will start tracing it.
2364 """
maruel@chromium.orgbe75f282012-11-02 19:34:37 +00002365 out = (
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002366 'inline int PID = %d;\n'
2367 'inline string SCRIPT = "%s";\n'
2368 'inline int FILE_ID = %d;\n'
2369 '\n'
2370 '%s') % (
2371 os.getpid(),
2372 self._script,
2373 self._dummy_file_id,
maruel@chromium.orgbe75f282012-11-02 19:34:37 +00002374 self.D_CODE)
2375 if os.environ.get('TRACE_INPUTS_DTRACE_ENABLE_EXECVE') == '1':
2376 # Do not enable by default since it tends to spew dtrace: error lines
2377 # because the execve() parameters are not in valid memory at the time of
2378 # logging.
2379 # TODO(maruel): Find a way to make this reliable since it's useful but
2380 # only works in limited/trivial uses cases for now.
2381 out += self.D_CODE_EXECVE
2382 return out
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002383
2384 def trace(self, cmd, cwd, tracename, output):
2385 """Runs dtrace on an executable.
2386
2387 This dtruss is broken when it starts the process itself or when tracing
2388 child processes, this code starts a wrapper process
2389 generated with create_thunk() which starts the executable to trace.
2390 """
2391 logging.info('trace(%s, %s, %s, %s)' % (cmd, cwd, tracename, output))
2392 assert os.path.isabs(cmd[0]), cmd[0]
2393 assert os.path.isabs(cwd), cwd
2394 assert os.path.normpath(cwd) == cwd, cwd
2395 with self._lock:
2396 if not self._initialized:
2397 raise TracingFailure(
2398 'Called Tracer.trace() on an unitialized object',
2399 None, None, None, tracename)
2400 assert tracename not in (i['trace'] for i in self._traces)
2401
2402 # Starts the script wrapper to start the child process. This signals the
2403 # dtrace script that this process is to be traced.
2404 stdout = stderr = None
2405 if output:
2406 stdout = subprocess.PIPE
2407 stderr = subprocess.STDOUT
2408 child_cmd = [
2409 sys.executable,
2410 self._script,
2411 tracename,
2412 ]
2413 # Call a dummy function so that dtrace knows I'm about to launch a process
2414 # that needs to be traced.
2415 # Yummy.
2416 child = subprocess.Popen(
2417 child_cmd + fix_python_path(cmd),
2418 stdin=subprocess.PIPE,
2419 stdout=stdout,
2420 stderr=stderr,
2421 cwd=cwd)
2422 logging.debug('Started child pid: %d' % child.pid)
2423
2424 out = child.communicate()[0]
2425 # This doesn't mean tracing is done, one of the grand-child process may
2426 # still be alive. It will be tracked with the dtrace script.
2427
2428 with self._lock:
2429 assert tracename not in (i['trace'] for i in self._traces)
2430 self._traces.append(
2431 {
2432 'cmd': cmd,
2433 'cwd': cwd,
2434 # The pid of strace process, not very useful.
2435 'pid': child.pid,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002436 'output': out,
maruel@chromium.org964ddde2012-10-29 18:13:46 +00002437 'trace': tracename,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002438 })
2439 return child.returncode, out
2440
2441 def close(self, timeout=None):
2442 """Terminates dtrace."""
2443 logging.debug('close(%s)' % timeout)
2444 try:
2445 try:
2446 super(Dtrace.Tracer, self).close(timeout)
2447 # Signal dtrace that it should stop now.
maruel@chromium.orgf43e68b2012-10-15 20:23:10 +00002448 # ftruncate doesn't exist on Windows.
2449 os.ftruncate(self._dummy_file_id, 0) # pylint: disable=E1101
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002450 if timeout:
2451 start = time.time()
2452 # Use polling. :/
2453 while (self._dtrace.poll() is None and
2454 (time.time() - start) < timeout):
2455 time.sleep(0.1)
2456 self._dtrace.kill()
2457 self._dtrace.wait()
2458 finally:
2459 # Make sure to kill it in any case.
2460 if self._dtrace.poll() is None:
2461 try:
2462 self._dtrace.kill()
2463 self._dtrace.wait()
2464 except OSError:
2465 pass
2466
2467 if self._dtrace.returncode != 0:
2468 # Warn about any dtrace failure but basically ignore it.
2469 print 'dtrace failure: %s' % self._dtrace.returncode
2470 finally:
2471 os.close(self._dummy_file_id)
2472 os.remove(self._dummy_file_name)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002473
2474 def post_process_log(self):
2475 """Sorts the log back in order when each call occured.
2476
2477 dtrace doesn't save the buffer in strict order since it keeps one buffer
2478 per CPU.
2479 """
2480 super(Dtrace.Tracer, self).post_process_log()
2481 logname = self._logname + '.log'
2482 with open(logname, 'rb') as logfile:
2483 lines = [l for l in logfile if l.strip()]
2484 errors = [l for l in lines if l.startswith('dtrace:')]
2485 if errors:
2486 raise TracingFailure(
2487 'Found errors in the trace: %s' % '\n'.join(errors),
2488 None, None, None, logname)
2489 try:
2490 lines = sorted(lines, key=lambda l: int(l.split(' ', 1)[0]))
2491 except ValueError:
2492 raise TracingFailure(
2493 'Found errors in the trace: %s' % '\n'.join(
2494 l for l in lines if l.split(' ', 1)[0].isdigit()),
2495 None, None, None, logname)
2496 with open(logname, 'wb') as logfile:
2497 logfile.write(''.join(lines))
2498
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00002499 def __init__(self, use_sudo=None):
2500 super(Dtrace, self).__init__()
2501 self.use_sudo = use_sudo
2502
2503 def get_tracer(self, logname):
2504 return self.Tracer(logname, self.use_sudo)
2505
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002506 @staticmethod
2507 def clean_trace(logname):
2508 for ext in ('', '.log'):
2509 if os.path.isfile(logname + ext):
2510 os.remove(logname + ext)
2511
2512 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00002513 def parse_log(cls, logname, blacklist, trace_name):
2514 logging.info('parse_log(%s, ..., %s)', logname, trace_name)
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00002515 assert os.path.isabs(logname)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002516
2517 def blacklist_more(filepath):
2518 # All the HFS metadata is in the form /.vol/...
2519 return blacklist(filepath) or re.match(r'^\/\.vol\/.+$', filepath)
2520
2521 data = read_json(logname)
2522 out = []
2523 for item in data['traces']:
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00002524 if trace_name and item['trace'] != trace_name:
2525 continue
maruel@chromium.org964ddde2012-10-29 18:13:46 +00002526 result = {
2527 'output': item['output'],
2528 'trace': item['trace'],
2529 }
2530 try:
2531 context = cls.Context(blacklist_more, item['pid'], item['cwd'])
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002532 # It's fine to assume the file as UTF-8: OSX enforces the file names to
2533 # be valid UTF-8 and we control the log output.
2534 for line in codecs.open(logname + '.log', 'rb', encoding='utf-8'):
maruel@chromium.org964ddde2012-10-29 18:13:46 +00002535 context.on_line(line)
2536 result['results'] = context.to_results()
2537 except TracingFailure:
2538 result['exception'] = sys.exc_info()
2539 out.append(result)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002540 return out
2541
2542
2543class LogmanTrace(ApiBase):
2544 """Uses the native Windows ETW based tracing functionality to trace a child
2545 process.
2546
2547 Caveat: this implementations doesn't track cwd or initial_cwd. It is because
2548 the Windows Kernel doesn't have a concept of 'current working directory' at
2549 all. A Win32 process has a map of current directories, one per drive letter
2550 and it is managed by the user mode kernel32.dll. In kernel, a file is always
2551 opened relative to another file_object or as an absolute path. All the current
2552 working directory logic is done in user mode.
2553 """
2554 class Context(ApiBase.Context):
2555 """Processes a ETW log line and keeps the list of existent and non
2556 existent files accessed.
2557
2558 Ignores directories.
2559 """
2560 # These indexes are for the stripped version in json.
2561 EVENT_NAME = 0
2562 TYPE = 1
2563 PID = 2
2564 TID = 3
2565 PROCESSOR_ID = 4
2566 TIMESTAMP = 5
2567 USER_DATA = 6
2568
2569 class Process(ApiBase.Context.Process):
2570 def __init__(self, *args):
2571 super(LogmanTrace.Context.Process, self).__init__(*args)
2572 # Handle file objects that succeeded.
2573 self.file_objects = {}
2574
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002575 def __init__(self, blacklist, thunk_pid, trace_name, thunk_cmd):
2576 logging.info(
2577 '%s(%d, %s, %s)', self.__class__.__name__, thunk_pid, trace_name,
2578 thunk_cmd)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002579 super(LogmanTrace.Context, self).__init__(blacklist)
2580 self._drive_map = DosDriveMap()
2581 # Threads mapping to the corresponding process id.
2582 self._threads_active = {}
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002583 # Process ID of the tracer, e.g. the temporary script created by
2584 # create_thunk(). This is tricky because the process id may have been
2585 # reused.
2586 self._thunk_pid = thunk_pid
2587 self._thunk_cmd = thunk_cmd
2588 self._trace_name = trace_name
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002589 self._line_number = 0
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002590 self._thunk_process = None
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002591
2592 def on_line(self, line):
2593 """Processes a json Event line."""
2594 self._line_number += 1
2595 try:
2596 # By Opcode
2597 handler = getattr(
2598 self,
2599 'handle_%s_%s' % (line[self.EVENT_NAME], line[self.TYPE]),
2600 None)
2601 if not handler:
2602 raise TracingFailure(
2603 'Unexpected event %s_%s' % (
2604 line[self.EVENT_NAME], line[self.TYPE]),
2605 None, None, None)
2606 handler(line)
2607 except TracingFailure, e:
2608 # Hack in the values since the handler could be a static function.
2609 e.pid = line[self.PID]
2610 e.line = line
2611 e.line_number = self._line_number
2612 # Re-raise the modified exception.
2613 raise
2614 except (KeyError, NotImplementedError, ValueError), e:
2615 raise TracingFailure(
2616 'Trace generated a %s exception: %s' % (
2617 e.__class__.__name__, str(e)),
2618 line[self.PID],
2619 self._line_number,
2620 line,
2621 e)
2622
2623 def to_results(self):
2624 if not self.root_process:
2625 raise TracingFailure(
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002626 'Failed to detect the initial process %d' % self._thunk_pid,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002627 None, None, None)
2628 process = self.root_process.to_results_process()
2629 return Results(process)
2630
2631 def _thread_to_process(self, tid):
2632 """Finds the process from the thread id."""
2633 tid = int(tid, 16)
2634 pid = self._threads_active.get(tid)
2635 if not pid or not self._process_lookup.get(pid):
2636 return
2637 return self._process_lookup[pid]
2638
2639 @classmethod
2640 def handle_EventTrace_Header(cls, line):
2641 """Verifies no event was dropped, e.g. no buffer overrun occured."""
2642 BUFFER_SIZE = cls.USER_DATA
2643 #VERSION = cls.USER_DATA + 1
2644 #PROVIDER_VERSION = cls.USER_DATA + 2
2645 #NUMBER_OF_PROCESSORS = cls.USER_DATA + 3
2646 #END_TIME = cls.USER_DATA + 4
2647 #TIMER_RESOLUTION = cls.USER_DATA + 5
2648 #MAX_FILE_SIZE = cls.USER_DATA + 6
2649 #LOG_FILE_MODE = cls.USER_DATA + 7
2650 #BUFFERS_WRITTEN = cls.USER_DATA + 8
2651 #START_BUFFERS = cls.USER_DATA + 9
2652 #POINTER_SIZE = cls.USER_DATA + 10
2653 EVENTS_LOST = cls.USER_DATA + 11
2654 #CPU_SPEED = cls.USER_DATA + 12
2655 #LOGGER_NAME = cls.USER_DATA + 13
2656 #LOG_FILE_NAME = cls.USER_DATA + 14
2657 #BOOT_TIME = cls.USER_DATA + 15
2658 #PERF_FREQ = cls.USER_DATA + 16
2659 #START_TIME = cls.USER_DATA + 17
2660 #RESERVED_FLAGS = cls.USER_DATA + 18
2661 #BUFFERS_LOST = cls.USER_DATA + 19
2662 #SESSION_NAME_STRING = cls.USER_DATA + 20
2663 #LOG_FILE_NAME_STRING = cls.USER_DATA + 21
2664 if line[EVENTS_LOST] != '0':
2665 raise TracingFailure(
2666 ( '%s events were lost during trace, please increase the buffer '
2667 'size from %s') % (line[EVENTS_LOST], line[BUFFER_SIZE]),
2668 None, None, None)
2669
2670 def handle_FileIo_Cleanup(self, line):
2671 """General wisdom: if a file is closed, it's because it was opened.
2672
2673 Note that FileIo_Close is not used since if a file was opened properly but
2674 not closed before the process exits, only Cleanup will be logged.
2675 """
2676 #IRP = self.USER_DATA
2677 TTID = self.USER_DATA + 1 # Thread ID, that's what we want.
2678 FILE_OBJECT = self.USER_DATA + 2
2679 #FILE_KEY = self.USER_DATA + 3
2680 proc = self._thread_to_process(line[TTID])
2681 if not proc:
2682 # Not a process we care about.
2683 return
2684 file_object = line[FILE_OBJECT]
2685 if file_object in proc.file_objects:
2686 proc.add_file(proc.file_objects.pop(file_object), False)
2687
2688 def handle_FileIo_Create(self, line):
2689 """Handles a file open.
2690
2691 All FileIo events are described at
2692 http://msdn.microsoft.com/library/windows/desktop/aa363884.aspx
2693 for some value of 'description'.
2694
2695 " (..) process and thread id values of the IO events (..) are not valid "
2696 http://msdn.microsoft.com/magazine/ee358703.aspx
2697
2698 The FileIo.Create event doesn't return if the CreateFile() call
2699 succeeded, so keep track of the file_object and check that it is
2700 eventually closed with FileIo_Cleanup.
2701 """
2702 #IRP = self.USER_DATA
2703 TTID = self.USER_DATA + 1 # Thread ID, that's what we want.
2704 FILE_OBJECT = self.USER_DATA + 2
2705 #CREATE_OPTIONS = self.USER_DATA + 3
2706 #FILE_ATTRIBUTES = self.USER_DATA + 4
2707 #self.USER_DATA + SHARE_ACCESS = 5
2708 OPEN_PATH = self.USER_DATA + 6
2709
2710 proc = self._thread_to_process(line[TTID])
2711 if not proc:
2712 # Not a process we care about.
2713 return
2714
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002715 raw_path = line[OPEN_PATH]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002716 # Ignore directories and bare drive right away.
2717 if raw_path.endswith(os.path.sep):
2718 return
2719 filepath = self._drive_map.to_win32(raw_path)
2720 # Ignore bare drive right away. Some may still fall through with format
2721 # like '\\?\X:'
2722 if len(filepath) == 2:
2723 return
2724 file_object = line[FILE_OBJECT]
2725 if os.path.isdir(filepath):
2726 # There is no O_DIRECTORY equivalent on Windows. The closed is
2727 # FILE_FLAG_BACKUP_SEMANTICS but it's not exactly right either. So
2728 # simply discard directories are they are found.
2729 return
2730 # Override any stale file object
2731 proc.file_objects[file_object] = filepath
2732
2733 def handle_FileIo_Rename(self, line):
2734 # TODO(maruel): Handle?
2735 pass
2736
2737 def handle_Process_End(self, line):
2738 pid = line[self.PID]
2739 if self._process_lookup.get(pid):
2740 logging.info('Terminated: %d' % pid)
2741 self._process_lookup[pid] = None
2742 else:
2743 logging.debug('Terminated: %d' % pid)
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002744 if self._thunk_process and self._thunk_process.pid == pid:
2745 self._thunk_process = None
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002746
2747 def handle_Process_Start(self, line):
2748 """Handles a new child process started by PID."""
2749 #UNIQUE_PROCESS_KEY = self.USER_DATA
2750 PROCESS_ID = self.USER_DATA + 1
2751 #PARENT_PID = self.USER_DATA + 2
2752 #SESSION_ID = self.USER_DATA + 3
2753 #EXIT_STATUS = self.USER_DATA + 4
2754 #DIRECTORY_TABLE_BASE = self.USER_DATA + 5
2755 #USER_SID = self.USER_DATA + 6
2756 IMAGE_FILE_NAME = self.USER_DATA + 7
2757 COMMAND_LINE = self.USER_DATA + 8
2758
2759 ppid = line[self.PID]
2760 pid = int(line[PROCESS_ID], 16)
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002761 command_line = CommandLineToArgvW(line[COMMAND_LINE])
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002762 logging.debug(
2763 'New process %d->%d (%s) %s' %
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002764 (ppid, pid, line[IMAGE_FILE_NAME], command_line))
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002765
2766 if pid == self._thunk_pid:
2767 # Need to ignore processes we don't know about because the log is
2768 # system-wide. self._thunk_pid shall start only one process.
2769 # This is tricky though because Windows *loves* to reuse process id and
2770 # it happens often that the process ID of the thunk script created by
2771 # create_thunk() is reused. So just detecting the pid here is not
2772 # sufficient, we must confirm the command line.
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002773 if command_line[:len(self._thunk_cmd)] != self._thunk_cmd:
2774 logging.info(
2775 'Ignoring duplicate pid %d for %s: %s while searching for %s',
2776 pid, self._trace_name, command_line, self._thunk_cmd)
2777 return
2778
2779 # TODO(maruel): The check is quite weak. Add the thunk path.
2780 if self._thunk_process:
2781 raise TracingFailure(
2782 ( 'Parent process is _thunk_pid(%d) but thunk_process(%d) is '
2783 'already set') % (self._thunk_pid, self._thunk_process.pid),
2784 None, None, None)
2785 proc = self.Process(self.blacklist, pid, None)
2786 self._thunk_process = proc
2787 return
2788 elif ppid == self._thunk_pid and self._thunk_process:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002789 proc = self.Process(self.blacklist, pid, None)
2790 self.root_process = proc
2791 ppid = None
2792 elif self._process_lookup.get(ppid):
2793 proc = self.Process(self.blacklist, pid, None)
2794 self._process_lookup[ppid].children.append(proc)
2795 else:
2796 # Ignore
2797 return
2798 self._process_lookup[pid] = proc
2799
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002800 proc.command = command_line
2801 proc.executable = line[IMAGE_FILE_NAME]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002802 # proc.command[0] may be the absolute path of 'executable' but it may be
2803 # anything else too. If it happens that command[0] ends with executable,
2804 # use it, otherwise defaults to the base name.
2805 cmd0 = proc.command[0].lower()
2806 if not cmd0.endswith('.exe'):
2807 # TODO(maruel): That's not strictly true either.
2808 cmd0 += '.exe'
2809 if cmd0.endswith(proc.executable) and os.path.isfile(cmd0):
2810 # Fix the path.
2811 cmd0 = cmd0.replace('/', os.path.sep)
2812 cmd0 = os.path.normpath(cmd0)
2813 proc.executable = get_native_path_case(cmd0)
2814 logging.info(
2815 'New child: %s -> %d %s' % (ppid, pid, proc.executable))
2816
2817 def handle_Thread_End(self, line):
2818 """Has the same parameters as Thread_Start."""
2819 tid = int(line[self.TID], 16)
2820 self._threads_active.pop(tid, None)
2821
2822 def handle_Thread_Start(self, line):
2823 """Handles a new thread created.
2824
2825 Do not use self.PID here since a process' initial thread is created by
2826 the parent process.
2827 """
2828 PROCESS_ID = self.USER_DATA
2829 TTHREAD_ID = self.USER_DATA + 1
2830 #STACK_BASE = self.USER_DATA + 2
2831 #STACK_LIMIT = self.USER_DATA + 3
2832 #USER_STACK_BASE = self.USER_DATA + 4
2833 #USER_STACK_LIMIT = self.USER_DATA + 5
2834 #AFFINITY = self.USER_DATA + 6
2835 #WIN32_START_ADDR = self.USER_DATA + 7
2836 #TEB_BASE = self.USER_DATA + 8
2837 #SUB_PROCESS_TAG = self.USER_DATA + 9
2838 #BASE_PRIORITY = self.USER_DATA + 10
2839 #PAGE_PRIORITY = self.USER_DATA + 11
2840 #IO_PRIORITY = self.USER_DATA + 12
2841 #THREAD_FLAGS = self.USER_DATA + 13
2842 # Do not use self.PID here since a process' initial thread is created by
2843 # the parent process.
2844 pid = int(line[PROCESS_ID], 16)
2845 tid = int(line[TTHREAD_ID], 16)
2846 logging.debug('New thread pid:%d, tid:%d' % (pid, tid))
2847 self._threads_active[tid] = pid
2848
2849 @classmethod
2850 def supported_events(cls):
2851 """Returns all the procesed events."""
2852 out = []
2853 for member in dir(cls):
2854 match = re.match(r'^handle_([A-Za-z]+)_([A-Za-z]+)$', member)
2855 if match:
2856 out.append(match.groups())
2857 return out
2858
2859 class Tracer(ApiBase.Tracer):
2860 # The basic headers.
2861 EXPECTED_HEADER = [
2862 u'Event Name',
2863 u'Type',
2864 u'Event ID',
2865 u'Version',
2866 u'Channel',
2867 u'Level', # 5
2868 u'Opcode',
2869 u'Task',
2870 u'Keyword',
2871 u'PID',
2872 u'TID', # 10
2873 u'Processor Number',
2874 u'Instance ID',
2875 u'Parent Instance ID',
2876 u'Activity ID',
2877 u'Related Activity ID', # 15
2878 u'Clock-Time',
2879 u'Kernel(ms)', # Both have a resolution of ~15ms which makes them
2880 u'User(ms)', # pretty much useless.
2881 u'User Data', # Extra arguments that are event-specific.
2882 ]
2883 # Only the useful headers common to all entries are listed there. Any column
2884 # at 19 or higher is dependent on the specific event.
2885 EVENT_NAME = 0
2886 TYPE = 1
2887 PID = 9
2888 TID = 10
2889 PROCESSOR_ID = 11
2890 TIMESTAMP = 16
2891 NULL_GUID = '{00000000-0000-0000-0000-000000000000}'
2892 USER_DATA = 19
2893
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002894 class CsvReader(object):
2895 """CSV reader that reads files generated by tracerpt.exe.
2896
2897 csv.reader() fails to read them properly, it mangles file names quoted
2898 with "" with a comma in it.
2899 """
2900 # 0. Had a ',' or one of the following ' ' after a comma, next should
2901 # be ' ', '"' or string or ',' for an empty field.
2902 ( HAD_DELIMITER,
2903 # 1. Processing an unquoted field up to ','.
2904 IN_STR,
2905 # 2. Processing a new field starting with '"'.
2906 STARTING_STR_QUOTED,
2907 # 3. Second quote in a row at the start of a field. It could be either
2908 # '""foo""' or '""'. Who the hell thought it was a great idea to use
2909 # the same character for delimiting and escaping?
2910 STARTING_SECOND_QUOTE,
2911 # 4. A quote inside a quoted string where the previous character was
2912 # not a quote, so the string is not empty. Can be either: end of a
2913 # quoted string (a delimiter) or a quote escape. The next char must be
2914 # either '"' or ','.
2915 HAD_QUOTE_IN_QUOTED,
2916 # 5. Second quote inside a quoted string.
2917 HAD_SECOND_QUOTE_IN_A_ROW_IN_QUOTED,
2918 # 6. Processing a field that started with '"'.
2919 IN_STR_QUOTED) = range(7)
2920
2921 def __init__(self, f):
2922 self.f = f
2923
2924 def __iter__(self):
2925 return self
2926
2927 def next(self):
2928 """Splits the line in fields."""
2929 line = self.f.readline()
2930 if not line:
2931 raise StopIteration()
2932 line = line.strip()
2933 fields = []
2934 state = self.HAD_DELIMITER
2935 for i, c in enumerate(line):
2936 if state == self.HAD_DELIMITER:
2937 if c == ',':
2938 # Empty field.
2939 fields.append('')
2940 elif c == ' ':
2941 # Ignore initial whitespaces
2942 pass
2943 elif c == '"':
2944 state = self.STARTING_STR_QUOTED
2945 fields.append('')
2946 else:
2947 # Start of a new field.
2948 state = self.IN_STR
2949 fields.append(c)
2950
2951 elif state == self.IN_STR:
2952 # Do not accept quote inside unquoted field.
2953 assert c != '"', (i, c, line, fields)
2954 if c == ',':
2955 fields[-1] = fields[-1].strip()
2956 state = self.HAD_DELIMITER
2957 else:
2958 fields[-1] = fields[-1] + c
2959
2960 elif state == self.STARTING_STR_QUOTED:
2961 if c == '"':
2962 # Do not store the character yet.
2963 state = self.STARTING_SECOND_QUOTE
2964 else:
2965 state = self.IN_STR_QUOTED
2966 fields[-1] = fields[-1] + c
2967
2968 elif state == self.STARTING_SECOND_QUOTE:
2969 if c == ',':
2970 # It was an empty field. '""' == ''.
2971 state = self.HAD_DELIMITER
2972 else:
2973 fields[-1] = fields[-1] + '"' + c
2974 state = self.IN_STR_QUOTED
2975
2976 elif state == self.HAD_QUOTE_IN_QUOTED:
2977 if c == ',':
2978 # End of the string.
2979 state = self.HAD_DELIMITER
2980 elif c == '"':
2981 state = self.HAD_SECOND_QUOTE_IN_A_ROW_IN_QUOTED
2982 else:
2983 # The previous double-quote was just an unescaped quote.
2984 fields[-1] = fields[-1] + '"' + c
2985 state = self.IN_STR_QUOTED
2986
2987 elif state == self.HAD_SECOND_QUOTE_IN_A_ROW_IN_QUOTED:
2988 if c == ',':
2989 # End of the string.
2990 state = self.HAD_DELIMITER
2991 fields[-1] = fields[-1] + '"'
2992 else:
maruel@chromium.org261d1352013-02-25 19:23:20 +00002993 # That's just how the logger rolls. Revert back to appending the
2994 # char and "guess" it was a quote in a double-quoted string.
2995 state = self.IN_STR_QUOTED
2996 fields[-1] = fields[-1] + '"' + c
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002997
2998 elif state == self.IN_STR_QUOTED:
2999 if c == '"':
3000 # Could be a delimiter or an escape.
3001 state = self.HAD_QUOTE_IN_QUOTED
3002 else:
3003 fields[-1] = fields[-1] + c
3004
3005 if state == self.HAD_SECOND_QUOTE_IN_A_ROW_IN_QUOTED:
3006 fields[-1] = fields[-1] + '"'
3007 else:
3008 assert state in (
3009 # Terminated with a normal field.
3010 self.IN_STR,
3011 # Terminated with an empty field.
3012 self.STARTING_SECOND_QUOTE,
3013 # Terminated with a normal quoted field.
3014 self.HAD_QUOTE_IN_QUOTED), (
3015 line, state, fields)
3016 return fields
3017
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003018 def __init__(self, logname):
3019 """Starts the log collection.
3020
3021 Requires administrative access. logman.exe is synchronous so no need for a
3022 "warmup" call. 'Windows Kernel Trace' is *localized* so use its GUID
3023 instead. The GUID constant name is SystemTraceControlGuid. Lovely.
3024
3025 One can get the list of potentially interesting providers with:
3026 "logman query providers | findstr /i file"
3027 """
3028 super(LogmanTrace.Tracer, self).__init__(logname)
3029 self._script = create_thunk()
3030 cmd_start = [
3031 'logman.exe',
3032 'start',
3033 'NT Kernel Logger',
3034 '-p', '{9e814aad-3204-11d2-9a82-006008a86939}',
3035 # splitio,fileiocompletion,syscall,file,cswitch,img
3036 '(process,fileio,thread)',
3037 '-o', self._logname + '.etl',
3038 '-ets', # Send directly to kernel
3039 # Values extracted out of thin air.
3040 # Event Trace Session buffer size in kb.
3041 '-bs', '10240',
3042 # Number of Event Trace Session buffers.
3043 '-nb', '16', '256',
3044 ]
3045 logging.debug('Running: %s' % cmd_start)
3046 try:
3047 subprocess.check_call(
3048 cmd_start,
3049 stdin=subprocess.PIPE,
3050 stdout=subprocess.PIPE,
3051 stderr=subprocess.STDOUT)
3052 except subprocess.CalledProcessError, e:
3053 if e.returncode == -2147024891:
3054 print >> sys.stderr, 'Please restart with an elevated admin prompt'
3055 elif e.returncode == -2144337737:
3056 print >> sys.stderr, (
3057 'A kernel trace was already running, stop it and try again')
3058 raise
3059
3060 def trace(self, cmd, cwd, tracename, output):
3061 logging.info('trace(%s, %s, %s, %s)' % (cmd, cwd, tracename, output))
3062 assert os.path.isabs(cmd[0]), cmd[0]
3063 assert os.path.isabs(cwd), cwd
3064 assert os.path.normpath(cwd) == cwd, cwd
3065 with self._lock:
3066 if not self._initialized:
3067 raise TracingFailure(
3068 'Called Tracer.trace() on an unitialized object',
3069 None, None, None, tracename)
3070 assert tracename not in (i['trace'] for i in self._traces)
3071
3072 # Use "logman -?" for help.
3073
3074 stdout = stderr = None
3075 if output:
3076 stdout = subprocess.PIPE
3077 stderr = subprocess.STDOUT
3078
3079 # Run the child process.
3080 logging.debug('Running: %s' % cmd)
3081 # Use the temporary script generated with create_thunk() so we have a
3082 # clear pid owner. Since trace_inputs.py can be used as a library and
3083 # could trace multiple processes simultaneously, it makes it more complex
3084 # if the executable to be traced is executed directly here. It also solves
3085 # issues related to logman.exe that needs to be executed to control the
3086 # kernel trace.
3087 child_cmd = [
3088 sys.executable,
3089 self._script,
3090 tracename,
3091 ]
3092 child = subprocess.Popen(
3093 child_cmd + fix_python_path(cmd),
3094 cwd=cwd,
3095 stdin=subprocess.PIPE,
3096 stdout=stdout,
3097 stderr=stderr)
3098 logging.debug('Started child pid: %d' % child.pid)
3099 out = child.communicate()[0]
3100 # This doesn't mean all the grand-children are done. Sadly, we don't have
3101 # a good way to determine that.
3102
3103 with self._lock:
3104 assert tracename not in (i['trace'] for i in self._traces)
3105 self._traces.append({
maruel@chromium.orgf43e68b2012-10-15 20:23:10 +00003106 'cmd': cmd,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003107 'cwd': cwd,
maruel@chromium.org964ddde2012-10-29 18:13:46 +00003108 'output': out,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003109 'pid': child.pid,
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00003110 # Used to figure out the real process when process ids are reused.
3111 'thunk_cmd': child_cmd,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003112 'trace': tracename,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003113 })
3114
3115 return child.returncode, out
3116
3117 def close(self, _timeout=None):
3118 """Stops the kernel log collection and converts the traces to text
3119 representation.
3120 """
3121 with self._lock:
maruel@chromium.org808e3f22013-05-16 16:42:54 +00003122 try:
3123 super(LogmanTrace.Tracer, self).close()
3124 finally:
3125 cmd_stop = [
3126 'logman.exe',
3127 'stop',
3128 'NT Kernel Logger',
3129 '-ets', # Sends the command directly to the kernel.
3130 ]
3131 logging.debug('Running: %s' % cmd_stop)
3132 subprocess.check_call(
3133 cmd_stop,
3134 stdin=subprocess.PIPE,
3135 stdout=subprocess.PIPE,
3136 stderr=subprocess.STDOUT)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003137
3138 def post_process_log(self):
3139 """Converts the .etl file into .csv then into .json."""
3140 super(LogmanTrace.Tracer, self).post_process_log()
3141 logformat = 'csv'
3142 self._convert_log(logformat)
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003143 self._trim_log(logformat)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003144
maruel@chromium.org808e3f22013-05-16 16:42:54 +00003145 def _gen_logdata(self):
3146 return {
3147 'format': 'csv',
3148 'traces': self._traces,
3149 }
3150
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003151 def _trim_log(self, logformat):
3152 """Reduces the amount of data in original log by generating a 'reduced'
3153 log.
3154 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003155 if logformat == 'csv_utf16':
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003156 file_handle = codecs.open(
3157 self._logname + '.' + logformat, 'r', encoding='utf-16')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003158
3159 elif logformat == 'csv':
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003160 assert sys.getfilesystemencoding() == 'mbcs'
3161 file_handle = codecs.open(
3162 self._logname + '.' + logformat, 'r',
3163 encoding=get_current_encoding())
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003164
3165 supported_events = LogmanTrace.Context.supported_events()
3166
3167 def trim(generator):
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003168 """Loads items from the generator and returns the interesting data.
3169
3170 It filters out any uninteresting line and reduce the amount of data in
3171 the trace.
3172 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003173 for index, line in enumerate(generator):
3174 if not index:
3175 if line != self.EXPECTED_HEADER:
3176 raise TracingFailure(
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003177 'Found malformed header: %s' % line,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003178 None, None, None)
3179 continue
3180 # As you can see, the CSV is full of useful non-redundant information:
3181 if (line[2] != '0' or # Event ID
3182 line[3] not in ('2', '3') or # Version
3183 line[4] != '0' or # Channel
3184 line[5] != '0' or # Level
3185 line[7] != '0' or # Task
3186 line[8] != '0x0000000000000000' or # Keyword
3187 line[12] != '' or # Instance ID
3188 line[13] != '' or # Parent Instance ID
3189 line[14] != self.NULL_GUID or # Activity ID
3190 line[15] != ''): # Related Activity ID
3191 raise TracingFailure(
3192 'Found unexpected values in line: %s' % ' '.join(line),
3193 None, None, None)
3194
3195 if (line[self.EVENT_NAME], line[self.TYPE]) not in supported_events:
3196 continue
3197
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003198 yield [
3199 line[self.EVENT_NAME],
3200 line[self.TYPE],
3201 line[self.PID],
3202 line[self.TID],
3203 line[self.PROCESSOR_ID],
3204 line[self.TIMESTAMP],
3205 ] + line[self.USER_DATA:]
3206
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003207 # must not convert the trim() call into a list, since it will use too much
3208 # memory for large trace. use a csv file as a workaround since the json
3209 # parser requires a complete in-memory file.
3210 with open('%s.preprocessed' % self._logname, 'wb') as f:
3211 # $ and * can't be used in file name on windows, reducing the likelihood
3212 # of having to escape a string.
3213 out = csv.writer(
3214 f, delimiter='$', quotechar='*', quoting=csv.QUOTE_MINIMAL)
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003215 for line in trim(self.CsvReader(file_handle)):
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003216 out.writerow([s.encode('utf-8') for s in line])
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003217
3218 def _convert_log(self, logformat):
3219 """Converts the ETL trace to text representation.
3220
3221 Normally, 'csv' is sufficient. If complex scripts are used (like eastern
3222 languages), use 'csv_utf16'. If localization gets in the way, use 'xml'.
3223
3224 Arguments:
3225 - logformat: Text format to be generated, csv, csv_utf16 or xml.
3226
3227 Use "tracerpt -?" for help.
3228 """
3229 LOCALE_INVARIANT = 0x7F
3230 windll.kernel32.SetThreadLocale(LOCALE_INVARIANT)
3231 cmd_convert = [
3232 'tracerpt.exe',
3233 '-l', self._logname + '.etl',
3234 '-o', self._logname + '.' + logformat,
3235 '-gmt', # Use UTC
3236 '-y', # No prompt
3237 # Use -of XML to get the header of each items after column 19, e.g. all
3238 # the actual headers of 'User Data'.
3239 ]
3240
3241 if logformat == 'csv':
3242 # tracerpt localizes the 'Type' column, for major brainfuck
3243 # entertainment. I can't imagine any sane reason to do that.
3244 cmd_convert.extend(['-of', 'CSV'])
3245 elif logformat == 'csv_utf16':
3246 # This causes it to use UTF-16, which doubles the log size but ensures
3247 # the log is readable for non-ASCII characters.
3248 cmd_convert.extend(['-of', 'CSV', '-en', 'Unicode'])
3249 elif logformat == 'xml':
3250 cmd_convert.extend(['-of', 'XML'])
3251 else:
3252 raise ValueError('Unexpected log format \'%s\'' % logformat)
3253 logging.debug('Running: %s' % cmd_convert)
3254 # This can takes tens of minutes for large logs.
3255 # Redirects all output to stderr.
3256 subprocess.check_call(
3257 cmd_convert,
3258 stdin=subprocess.PIPE,
3259 stdout=sys.stderr,
3260 stderr=sys.stderr)
3261
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00003262 def __init__(self, use_sudo=False): # pylint: disable=W0613
3263 super(LogmanTrace, self).__init__()
3264 # Ignore use_sudo. It's irrelevant on Windows but kept to simplify the API.
3265
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003266 @staticmethod
3267 def clean_trace(logname):
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003268 for ext in ('', '.csv', '.etl', '.json', '.xml', '.preprocessed'):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003269 if os.path.isfile(logname + ext):
3270 os.remove(logname + ext)
3271
3272 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003273 def parse_log(cls, logname, blacklist, trace_name):
3274 logging.info('parse_log(%s, ..., %s)', logname, trace_name)
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00003275 assert os.path.isabs(logname)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003276
3277 def blacklist_more(filepath):
3278 # All the NTFS metadata is in the form x:\$EXTEND or stuff like that.
3279 return blacklist(filepath) or re.match(r'[A-Z]\:\\\$EXTEND', filepath)
3280
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003281 # Create a list of (Context, result_dict) tuples. This is necessary because
3282 # the csv file may be larger than the amount of available memory.
3283 contexes = [
3284 (
3285 cls.Context(
3286 blacklist_more, item['pid'], item['trace'], item['thunk_cmd']),
3287 {
3288 'output': item['output'],
3289 'trace': item['trace'],
3290 },
3291 )
3292 for item in read_json(logname)['traces']
3293 if not trace_name or item['trace'] == trace_name
3294 ]
3295
3296 # The log may be too large to fit in memory and it is not efficient to read
3297 # it multiple times, so multiplex the contexes instead, which is slightly
3298 # more awkward.
3299 with open('%s.preprocessed' % logname, 'rb') as f:
3300 lines = csv.reader(
3301 f, delimiter='$', quotechar='*', quoting=csv.QUOTE_MINIMAL)
3302 for encoded in lines:
3303 line = [s.decode('utf-8') for s in encoded]
3304 # Convert the PID in-place from hex.
3305 line[cls.Context.PID] = int(line[cls.Context.PID], 16)
3306 for context in contexes:
3307 if 'exception' in context[1]:
3308 continue
3309 try:
3310 context[0].on_line(line)
3311 except TracingFailure:
3312 context[1]['exception'] = sys.exc_info()
3313
3314 for context in contexes:
3315 if 'exception' in context[1]:
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003316 continue
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003317 context[1]['results'] = context[0].to_results()
3318
3319 return [context[1] for context in contexes]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003320
3321
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00003322def get_api(**kwargs):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003323 """Returns the correct implementation for the current OS."""
3324 if sys.platform == 'cygwin':
3325 raise NotImplementedError(
3326 'Not implemented for cygwin, start the script from Win32 python')
3327 flavors = {
3328 'win32': LogmanTrace,
3329 'darwin': Dtrace,
3330 'sunos5': Dtrace,
3331 'freebsd7': Dtrace,
3332 'freebsd8': Dtrace,
3333 }
3334 # Defaults to strace.
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00003335 return flavors.get(sys.platform, Strace)(**kwargs)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003336
3337
3338def extract_directories(root_dir, files, blacklist):
3339 """Detects if all the files in a directory are in |files| and if so, replace
3340 the individual files by a Results.Directory instance.
3341
3342 Takes a list of Results.File instances and returns a shorter list of
3343 Results.File and Results.Directory instances.
3344
3345 Arguments:
3346 - root_dir: Optional base directory that shouldn't be search further.
3347 - files: list of Results.File instances.
3348 - blacklist: regexp of files to ignore, for example r'.+\.pyc'.
3349 """
3350 logging.info(
3351 'extract_directories(%s, %d files, ...)' % (root_dir, len(files)))
3352 assert not (root_dir or '').endswith(os.path.sep), root_dir
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003353 # It is important for root_dir to not be a symlinked path, make sure to call
3354 # os.path.realpath() as needed.
3355 assert not root_dir or (
3356 os.path.realpath(get_native_path_case(root_dir)) == root_dir)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003357 assert not any(isinstance(f, Results.Directory) for f in files)
3358 # Remove non existent files.
3359 files = [f for f in files if f.existent]
3360 if not files:
3361 return files
3362 # All files must share the same root, which can be None.
3363 assert len(set(f.root for f in files)) == 1, set(f.root for f in files)
3364
3365 # Creates a {directory: {filename: File}} mapping, up to root.
3366 buckets = {}
3367 if root_dir:
3368 buckets[root_dir] = {}
3369 for fileobj in files:
3370 path = fileobj.full_path
3371 directory = os.path.dirname(path)
3372 assert directory
3373 # Do not use os.path.basename() so trailing os.path.sep is kept.
3374 basename = path[len(directory)+1:]
3375 files_in_directory = buckets.setdefault(directory, {})
3376 files_in_directory[basename] = fileobj
3377 # Add all the directories recursively up to root.
3378 while True:
3379 old_d = directory
3380 directory = os.path.dirname(directory)
3381 if directory + os.path.sep == root_dir or directory == old_d:
3382 break
3383 buckets.setdefault(directory, {})
3384
3385 root_prefix = len(root_dir) + 1 if root_dir else 0
3386 for directory in sorted(buckets, reverse=True):
maruel@chromium.orgb1678e62013-03-19 15:55:17 +00003387 if not os.path.isdir(directory):
3388 logging.debug(
3389 '%s was a directory but doesn\'t exist anymore; ignoring', directory)
3390 continue
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003391 actual = set(f for f in os.listdir(directory) if not blacklist(f))
3392 expected = set(buckets[directory])
3393 if not (actual - expected):
3394 parent = os.path.dirname(directory)
3395 buckets[parent][os.path.basename(directory)] = Results.Directory(
3396 root_dir,
3397 directory[root_prefix:],
3398 False,
3399 sum(f.size for f in buckets[directory].itervalues()),
3400 sum(f.nb_files for f in buckets[directory].itervalues()))
3401 # Remove the whole bucket.
3402 del buckets[directory]
3403
3404 # Reverse the mapping with what remains. The original instances are returned,
3405 # so the cached meta data is kept.
3406 files = sum((x.values() for x in buckets.itervalues()), [])
3407 return sorted(files, key=lambda x: x.path)
3408
3409
3410def trace(logfile, cmd, cwd, api, output):
3411 """Traces an executable. Returns (returncode, output) from api.
3412
3413 Arguments:
3414 - logfile: file to write to.
3415 - cmd: command to run.
3416 - cwd: current directory to start the process in.
3417 - api: a tracing api instance.
3418 - output: if True, returns output, otherwise prints it at the console.
3419 """
3420 cmd = fix_python_path(cmd)
3421 api.clean_trace(logfile)
3422 with api.get_tracer(logfile) as tracer:
3423 return tracer.trace(cmd, cwd, 'default', output)
3424
3425
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003426def CMDclean(args):
3427 """Cleans up traces."""
3428 parser = OptionParserTraceInputs(command='clean')
3429 options, args = parser.parse_args(args)
3430 api = get_api()
3431 api.clean_trace(options.log)
3432 return 0
3433
3434
3435def CMDtrace(args):
3436 """Traces an executable."""
3437 parser = OptionParserTraceInputs(command='trace')
3438 parser.allow_interspersed_args = False
3439 parser.add_option(
3440 '-q', '--quiet', action='store_true',
3441 help='Redirects traced executable output to /dev/null')
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00003442 parser.add_option(
3443 '-s', '--sudo', action='store_true',
3444 help='Use sudo when shelling out the tracer tool (ignored on Windows)')
3445 parser.add_option(
3446 '-n', '--no-sudo', action='store_false',
3447 help='Don\'t use sudo')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003448 options, args = parser.parse_args(args)
3449
3450 if not args:
3451 parser.error('Please provide a command to run')
3452
3453 if not os.path.isabs(args[0]) and os.access(args[0], os.X_OK):
3454 args[0] = os.path.abspath(args[0])
3455
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00003456 # options.sudo default value is None, which is to do whatever tracer defaults
3457 # do.
3458 api = get_api(use_sudo=options.sudo)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003459 return trace(options.log, args, os.getcwd(), api, options.quiet)[0]
3460
3461
3462def CMDread(args):
3463 """Reads the logs and prints the result."""
3464 parser = OptionParserTraceInputs(command='read')
3465 parser.add_option(
3466 '-V', '--variable',
3467 nargs=2,
3468 action='append',
3469 dest='variables',
3470 metavar='VAR_NAME directory',
3471 default=[],
3472 help=('Variables to replace relative directories against. Example: '
3473 '"-v \'$HOME\' \'/home/%s\'" will replace all occurence of your '
3474 'home dir with $HOME') % getpass.getuser())
3475 parser.add_option(
3476 '--root-dir',
3477 help='Root directory to base everything off it. Anything outside of this '
3478 'this directory will not be reported')
3479 parser.add_option(
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003480 '--trace-name',
3481 help='Only reads one of the trace. Defaults to reading all traces')
3482 parser.add_option(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003483 '-j', '--json', action='store_true',
3484 help='Outputs raw result data as json')
3485 parser.add_option(
3486 '-b', '--blacklist', action='append', default=[],
3487 help='List of regexp to use as blacklist filter')
3488 options, args = parser.parse_args(args)
3489
3490 if options.root_dir:
maruel@chromium.org7dc17b32013-04-02 22:27:05 +00003491 options.root_dir = get_native_path_case(
3492 unicode(os.path.abspath(options.root_dir)))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003493
3494 variables = dict(options.variables)
3495 api = get_api()
3496 def blacklist(f):
3497 return any(re.match(b, f) for b in options.blacklist)
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003498 data = api.parse_log(options.log, blacklist, options.trace_name)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003499 # Process each trace.
3500 output_as_json = []
maruel@chromium.org9e56b592013-05-21 16:56:05 +00003501 try:
3502 for item in data:
3503 if 'exception' in item:
3504 # Do not abort the other traces.
3505 print >> sys.stderr, (
3506 'Trace %s: Got an exception: %s' % (
3507 item['trace'], item['exception'][1]))
3508 continue
3509 results = item['results']
3510 if options.root_dir:
3511 results = results.strip_root(options.root_dir)
3512
3513 if options.json:
3514 output_as_json.append(results.flatten())
3515 else:
3516 simplified = extract_directories(
3517 options.root_dir, results.files, blacklist)
3518 simplified = [f.replace_variables(variables) for f in simplified]
3519 if len(data) > 1:
3520 print('Trace: %s' % item['trace'])
3521 print('Total: %d' % len(results.files))
3522 print('Non existent: %d' % len(results.non_existent))
3523 for f in results.non_existent:
3524 print(' %s' % f.path)
3525 print(
3526 'Interesting: %d reduced to %d' % (
3527 len(results.existent), len(simplified)))
3528 for f in simplified:
3529 print(' %s' % f.path)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003530
3531 if options.json:
maruel@chromium.org9e56b592013-05-21 16:56:05 +00003532 write_json(sys.stdout, output_as_json, False)
3533 except KeyboardInterrupt:
3534 return 1
3535 except IOError as e:
3536 if e.errno == errno.EPIPE:
3537 # Do not print a stack trace when the output is piped to less and the user
3538 # quits before the whole output was written.
3539 return 1
3540 raise
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003541 return 0
3542
3543
3544class OptionParserWithLogging(optparse.OptionParser):
3545 """Adds --verbose option."""
3546 def __init__(self, verbose=0, **kwargs):
3547 optparse.OptionParser.__init__(self, **kwargs)
3548 self.add_option(
3549 '-v', '--verbose',
3550 action='count',
3551 default=verbose,
3552 help='Use multiple times to increase verbosity')
3553
3554 def parse_args(self, *args, **kwargs):
3555 options, args = optparse.OptionParser.parse_args(self, *args, **kwargs)
3556 levels = [logging.ERROR, logging.INFO, logging.DEBUG]
3557 logging.basicConfig(
3558 level=levels[min(len(levels)-1, options.verbose)],
3559 format='%(levelname)5s %(module)15s(%(lineno)3d): %(message)s')
3560 return options, args
3561
3562
3563class OptionParserWithNiceDescription(OptionParserWithLogging):
3564 """Generates the description with the command's docstring."""
3565 def __init__(self, **kwargs):
3566 """Sets 'description' and 'usage' if not already specified."""
3567 command = kwargs.pop('command', 'help')
3568 kwargs.setdefault(
3569 'description',
3570 re.sub('[\r\n ]{2,}', ' ', get_command_handler(command).__doc__))
3571 kwargs.setdefault('usage', '%%prog %s [options]' % command)
3572 OptionParserWithLogging.__init__(self, **kwargs)
3573
3574
3575class OptionParserTraceInputs(OptionParserWithNiceDescription):
3576 """Adds automatic --log handling."""
3577 def __init__(self, **kwargs):
3578 OptionParserWithNiceDescription.__init__(self, **kwargs)
3579 self.add_option(
3580 '-l', '--log', help='Log file to generate or read, required')
3581
3582 def parse_args(self, *args, **kwargs):
3583 """Makes sure the paths make sense.
3584
3585 On Windows, / and \ are often mixed together in a path.
3586 """
3587 options, args = OptionParserWithNiceDescription.parse_args(
3588 self, *args, **kwargs)
3589 if not options.log:
3590 self.error('Must supply a log file with -l')
3591 options.log = os.path.abspath(options.log)
3592 return options, args
3593
3594
3595def extract_documentation():
3596 """Returns a dict {command: description} for each of documented command."""
3597 commands = (
3598 fn[3:]
3599 for fn in dir(sys.modules['__main__'])
3600 if fn.startswith('CMD') and get_command_handler(fn[3:]).__doc__)
3601 return dict((fn, get_command_handler(fn).__doc__) for fn in commands)
3602
3603
3604def CMDhelp(args):
3605 """Prints list of commands or help for a specific command."""
3606 doc = extract_documentation()
3607 # Calculates the optimal offset.
3608 offset = max(len(cmd) for cmd in doc)
3609 format_str = ' %-' + str(offset + 2) + 's %s'
3610 # Generate a one-liner documentation of each commands.
3611 commands_description = '\n'.join(
3612 format_str % (cmd, doc[cmd].split('\n')[0]) for cmd in sorted(doc))
3613
3614 parser = OptionParserWithNiceDescription(
3615 usage='%prog <command> [options]',
3616 description='Commands are:\n%s\n' % commands_description)
3617 parser.format_description = lambda _: parser.description
3618
3619 # Strip out any -h or --help argument.
3620 _, args = parser.parse_args([i for i in args if not i in ('-h', '--help')])
3621 if len(args) == 1:
3622 if not get_command_handler(args[0]):
3623 parser.error('Unknown command %s' % args[0])
3624 # The command was "%prog help command", replaces ourself with
3625 # "%prog command --help" so help is correctly printed out.
3626 return main(args + ['--help'])
3627 elif args:
3628 parser.error('Unknown argument "%s"' % ' '.join(args))
3629 parser.print_help()
3630 return 0
3631
3632
3633def get_command_handler(name):
3634 """Returns the command handler or CMDhelp if it doesn't exist."""
3635 return getattr(sys.modules['__main__'], 'CMD%s' % name, None)
3636
3637
3638def main_impl(argv):
3639 command = get_command_handler(argv[0] if argv else 'help')
3640 if not command:
3641 return CMDhelp(argv)
3642 return command(argv[1:])
3643
3644def main(argv):
maruel@chromium.orgac36fb72013-05-21 14:50:53 +00003645 disable_buffering()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003646 try:
3647 main_impl(argv)
3648 except TracingFailure, e:
3649 sys.stderr.write('\nError: ')
3650 sys.stderr.write(str(e))
3651 sys.stderr.write('\n')
3652 return 1
3653
3654
3655if __name__ == '__main__':
3656 sys.exit(main(sys.argv[1:]))