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