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