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