blob: d718a655b6ef10381b7f0797d651951c7de09d07 [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
csharp@chromium.orgfa672922012-11-02 17:43:31 +00001389 @parse_args(r'^\"(.+?)\", \d+', True)
1390 def handle_truncate(self, args, _result):
1391 self._handle_file(args[0], False)
1392
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001393 def handle_unlink(self, _args, _result):
1394 # In theory, the file had to be created anyway.
1395 pass
1396
csharp@chromium.org11ae0672012-10-30 14:53:17 +00001397 def handle_unlinkat(self, _args, _result):
1398 # In theory, the file had to be created anyway.
1399 pass
1400
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001401 def handle_statfs(self, _args, _result):
1402 pass
1403
csharp@chromium.org11ae0672012-10-30 14:53:17 +00001404 def handle_utimensat(self, _args, _result):
1405 pass
1406
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001407 def handle_vfork(self, args, result):
1408 self._handle_unknown('vfork', args, result)
1409
1410 @staticmethod
1411 def _handle_unknown(function, args, result):
1412 raise TracingFailure(
1413 'Unexpected/unimplemented trace %s(%s)= %s' %
1414 (function, args, result),
1415 None, None, None)
1416
1417 def _handle_file(self, filepath, touch_only):
1418 filepath = self.RelativePath(self.get_cwd(), filepath)
1419 #assert not touch_only, unicode(filepath)
1420 self.add_file(filepath, touch_only)
1421
1422 def __init__(self, blacklist, initial_cwd):
1423 super(Strace.Context, self).__init__(blacklist)
1424 self.initial_cwd = initial_cwd
1425
1426 def render(self):
1427 """Returns the string value of the initial cwd of the root process.
1428
1429 Used by RelativePath.
1430 """
1431 return self.initial_cwd
1432
1433 def on_line(self, pid, line):
1434 """Transfers control into the Process.on_line() function."""
1435 self.get_or_set_proc(pid).on_line(line.strip())
1436
1437 def to_results(self):
1438 """Finds back the root process and verify consistency."""
1439 # TODO(maruel): Absolutely unecessary, fix me.
1440 root = [p for p in self._process_lookup.itervalues() if not p.parentid]
1441 if len(root) != 1:
1442 raise TracingFailure(
1443 'Found internal inconsitency in process lifetime detection '
1444 'while finding the root process',
1445 None,
1446 None,
1447 None,
1448 sorted(p.pid for p in root))
1449 self.root_process = root[0]
1450 process = self.root_process.to_results_process()
1451 if sorted(self._process_lookup) != sorted(p.pid for p in process.all):
1452 raise TracingFailure(
1453 'Found internal inconsitency in process lifetime detection '
1454 'while looking for len(tree) == len(list)',
1455 None,
1456 None,
1457 None,
1458 sorted(self._process_lookup),
1459 sorted(p.pid for p in process.all))
1460 return Results(process)
1461
1462 def get_or_set_proc(self, pid):
1463 """Returns the Context.Process instance for this pid or creates a new one.
1464 """
1465 if not pid or not isinstance(pid, int):
1466 raise TracingFailure(
1467 'Unpexpected value for pid: %r' % pid,
1468 pid,
1469 None,
1470 None,
1471 pid)
1472 if pid not in self._process_lookup:
1473 self._process_lookup[pid] = self.Process(self, pid)
1474 return self._process_lookup[pid]
1475
1476 @classmethod
1477 def traces(cls):
1478 """Returns the list of all handled traces to pass this as an argument to
1479 strace.
1480 """
1481 prefix = 'handle_'
1482 return [i[len(prefix):] for i in dir(cls.Process) if i.startswith(prefix)]
1483
1484 class Tracer(ApiBase.Tracer):
1485 MAX_LEN = 256
1486
1487 def trace(self, cmd, cwd, tracename, output):
1488 """Runs strace on an executable."""
1489 logging.info('trace(%s, %s, %s, %s)' % (cmd, cwd, tracename, output))
1490 assert os.path.isabs(cmd[0]), cmd[0]
1491 assert os.path.isabs(cwd), cwd
1492 assert os.path.normpath(cwd) == cwd, cwd
1493 with self._lock:
1494 if not self._initialized:
1495 raise TracingFailure(
1496 'Called Tracer.trace() on an unitialized object',
1497 None, None, None, tracename)
1498 assert tracename not in (i['trace'] for i in self._traces)
1499 stdout = stderr = None
1500 if output:
1501 stdout = subprocess.PIPE
1502 stderr = subprocess.STDOUT
1503 # Ensure all file related APIs are hooked.
1504 traces = ','.join(Strace.Context.traces() + ['file'])
1505 trace_cmd = [
1506 'strace',
1507 '-ff',
1508 '-s', '%d' % self.MAX_LEN,
1509 '-e', 'trace=%s' % traces,
1510 '-o', self._logname + '.' + tracename,
1511 ]
1512 child = subprocess.Popen(
1513 trace_cmd + cmd,
1514 cwd=cwd,
1515 stdin=subprocess.PIPE,
1516 stdout=stdout,
1517 stderr=stderr)
1518 out = child.communicate()[0]
1519 # TODO(maruel): Walk the logs and figure out the root process would
1520 # simplify parsing the logs a *lot*.
1521 with self._lock:
1522 assert tracename not in (i['trace'] for i in self._traces)
1523 self._traces.append(
1524 {
1525 'cmd': cmd,
1526 'cwd': cwd,
maruel@chromium.org964ddde2012-10-29 18:13:46 +00001527 'output': out,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001528 # The pid of strace process, not very useful.
1529 'pid': child.pid,
1530 'trace': tracename,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001531 })
1532 return child.returncode, out
1533
1534 @staticmethod
1535 def clean_trace(logname):
1536 if os.path.isfile(logname):
1537 os.remove(logname)
1538 # Also delete any pid specific file from previous traces.
1539 for i in glob.iglob(logname + '.*'):
1540 if i.rsplit('.', 1)[1].isdigit():
1541 os.remove(i)
1542
1543 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001544 def parse_log(cls, logname, blacklist, trace_name):
1545 logging.info('parse_log(%s, ..., %s)', logname, trace_name)
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00001546 assert os.path.isabs(logname)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001547 data = read_json(logname)
1548 out = []
1549 for item in data['traces']:
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001550 if trace_name and item['trace'] != trace_name:
1551 continue
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001552 result = {
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001553 'output': item['output'],
maruel@chromium.org964ddde2012-10-29 18:13:46 +00001554 'trace': item['trace'],
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001555 }
1556 try:
1557 context = cls.Context(blacklist, item['cwd'])
1558 for pidfile in glob.iglob('%s.%s.*' % (logname, item['trace'])):
1559 pid = pidfile.rsplit('.', 1)[1]
1560 if pid.isdigit():
1561 pid = int(pid)
1562 # TODO(maruel): Load as utf-8
1563 for line in open(pidfile, 'rb'):
1564 context.on_line(pid, line)
1565 result['results'] = context.to_results()
maruel@chromium.org424f5692012-10-15 15:42:38 +00001566 except TracingFailure:
1567 result['exception'] = sys.exc_info()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001568 out.append(result)
1569 return out
1570
1571
1572class Dtrace(ApiBase):
1573 """Uses DTrace framework through dtrace. Requires root access.
1574
1575 Implies Mac OSX.
1576
1577 dtruss can't be used because it has compatibility issues with python.
1578
1579 Also, the pid->cwd handling needs to be done manually since OSX has no way to
1580 get the absolute path of the 'cwd' dtrace variable from the probe.
1581
1582 Also, OSX doesn't populate curpsinfo->pr_psargs properly, see
1583 https://discussions.apple.com/thread/1980539. So resort to handling execve()
1584 manually.
1585
1586 errno is not printed in the log since this implementation currently only cares
1587 about files that were successfully opened.
1588 """
1589 class Context(ApiBase.Context):
1590 # Format: index pid function(args)
1591 RE_HEADER = re.compile(r'^\d+ (\d+) ([a-zA-Z_\-]+)\((.*?)\)$')
1592
1593 # Arguments parsing.
1594 RE_DTRACE_BEGIN = re.compile(r'^\"(.+?)\"$')
1595 RE_CHDIR = re.compile(r'^\"(.+?)\"$')
1596 RE_EXECVE = re.compile(r'^\"(.+?)\", \[(\d+), (.+)\]$')
1597 RE_OPEN = re.compile(r'^\"(.+?)\", (0x[0-9a-z]+), (0x[0-9a-z]+)$')
1598 RE_PROC_START = re.compile(r'^(\d+), \"(.+?)\", (\d+)$')
1599 RE_RENAME = re.compile(r'^\"(.+?)\", \"(.+?)\"$')
1600
1601 O_DIRECTORY = 0x100000
1602
1603 class Process(ApiBase.Context.Process):
1604 def __init__(self, *args):
1605 super(Dtrace.Context.Process, self).__init__(*args)
1606 self.cwd = self.initial_cwd
1607
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001608 def __init__(self, blacklist, thunk_pid, initial_cwd):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001609 logging.info(
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001610 '%s(%d, %s)' % (self.__class__.__name__, thunk_pid, initial_cwd))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001611 super(Dtrace.Context, self).__init__(blacklist)
1612 # Process ID of the temporary script created by create_thunk().
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001613 self._thunk_pid = thunk_pid
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001614 self._initial_cwd = initial_cwd
1615 self._line_number = 0
1616
1617 def on_line(self, line):
1618 self._line_number += 1
1619 match = self.RE_HEADER.match(line)
1620 if not match:
1621 raise TracingFailure(
1622 'Found malformed line: %s' % line,
1623 None,
1624 self._line_number,
1625 line)
1626 fn = getattr(
1627 self,
1628 'handle_%s' % match.group(2).replace('-', '_'),
1629 self._handle_ignored)
1630 # It is guaranteed to succeed because of the regexp. Or at least I thought
1631 # it would.
1632 pid = int(match.group(1))
1633 try:
1634 return fn(pid, match.group(3))
1635 except TracingFailure, e:
1636 # Hack in the values since the handler could be a static function.
1637 e.pid = pid
1638 e.line = line
1639 e.line_number = self._line_number
1640 # Re-raise the modified exception.
1641 raise
1642 except (KeyError, NotImplementedError, ValueError), e:
1643 raise TracingFailure(
1644 'Trace generated a %s exception: %s' % (
1645 e.__class__.__name__, str(e)),
1646 pid,
1647 self._line_number,
1648 line,
1649 e)
1650
1651 def to_results(self):
1652 process = self.root_process.to_results_process()
1653 # Internal concistency check.
1654 if sorted(self._process_lookup) != sorted(p.pid for p in process.all):
1655 raise TracingFailure(
1656 'Found internal inconsitency in process lifetime detection '
1657 'while looking for len(tree) == len(list)',
1658 None,
1659 None,
1660 None,
1661 sorted(self._process_lookup),
1662 sorted(p.pid for p in process.all))
1663 return Results(process)
1664
1665 def handle_dtrace_BEGIN(self, _pid, args):
1666 if not self.RE_DTRACE_BEGIN.match(args):
1667 raise TracingFailure(
1668 'Found internal inconsitency in dtrace_BEGIN log line',
1669 None, None, None)
1670
1671 def handle_proc_start(self, pid, args):
1672 """Transfers cwd.
1673
1674 The dtrace script already takes care of only tracing the processes that
1675 are child of the traced processes so there is no need to verify the
1676 process hierarchy.
1677 """
1678 if pid in self._process_lookup:
1679 raise TracingFailure(
1680 'Found internal inconsitency in proc_start: %d started two times' %
1681 pid,
1682 None, None, None)
1683 match = self.RE_PROC_START.match(args)
1684 if not match:
1685 raise TracingFailure(
1686 'Failed to parse arguments: %s' % args,
1687 None, None, None)
1688 ppid = int(match.group(1))
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001689 if ppid == self._thunk_pid and not self.root_process:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001690 proc = self.root_process = self.Process(
1691 self.blacklist, pid, self._initial_cwd)
1692 elif ppid in self._process_lookup:
1693 proc = self.Process(self.blacklist, pid, self._process_lookup[ppid].cwd)
1694 self._process_lookup[ppid].children.append(proc)
1695 else:
1696 # Another process tree, ignore.
1697 return
1698 self._process_lookup[pid] = proc
1699 logging.debug(
1700 'New child: %s -> %d cwd:%s' %
1701 (ppid, pid, unicode(proc.initial_cwd)))
1702
1703 def handle_proc_exit(self, pid, _args):
1704 """Removes cwd."""
1705 if pid in self._process_lookup:
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001706 # self._thunk_pid is not traced itself and other traces run neither.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001707 self._process_lookup[pid].cwd = None
1708
1709 def handle_execve(self, pid, args):
1710 """Sets the process' executable.
1711
1712 TODO(maruel): Read command line arguments. See
1713 https://discussions.apple.com/thread/1980539 for an example.
1714 https://gist.github.com/1242279
1715
1716 Will have to put the answer at http://stackoverflow.com/questions/7556249.
1717 :)
1718 """
1719 if not pid in self._process_lookup:
1720 # Another process tree, ignore.
1721 return
1722 match = self.RE_EXECVE.match(args)
1723 if not match:
1724 raise TracingFailure(
1725 'Failed to parse arguments: %r' % args,
1726 None, None, None)
1727 proc = self._process_lookup[pid]
1728 proc.executable = match.group(1)
1729 proc.command = self.process_escaped_arguments(match.group(3))
1730 if int(match.group(2)) != len(proc.command):
1731 raise TracingFailure(
1732 'Failed to parse execve() arguments: %s' % args,
1733 None, None, None)
1734
1735 def handle_chdir(self, pid, args):
1736 """Updates cwd."""
1737 if pid not in self._process_lookup:
1738 # Another process tree, ignore.
1739 return
1740 cwd = self.RE_CHDIR.match(args).group(1)
1741 if not cwd.startswith('/'):
1742 cwd2 = os.path.join(self._process_lookup[pid].cwd, cwd)
1743 logging.debug('handle_chdir(%d, %s) -> %s' % (pid, cwd, cwd2))
1744 else:
1745 logging.debug('handle_chdir(%d, %s)' % (pid, cwd))
1746 cwd2 = cwd
1747 self._process_lookup[pid].cwd = cwd2
1748
1749 def handle_open_nocancel(self, pid, args):
1750 """Redirects to handle_open()."""
1751 return self.handle_open(pid, args)
1752
1753 def handle_open(self, pid, args):
1754 if pid not in self._process_lookup:
1755 # Another process tree, ignore.
1756 return
1757 match = self.RE_OPEN.match(args)
1758 if not match:
1759 raise TracingFailure(
1760 'Failed to parse arguments: %s' % args,
1761 None, None, None)
1762 flag = int(match.group(2), 16)
1763 if self.O_DIRECTORY & flag == self.O_DIRECTORY:
1764 # Ignore directories.
1765 return
1766 self._handle_file(pid, match.group(1))
1767
1768 def handle_rename(self, pid, args):
1769 if pid not in self._process_lookup:
1770 # Another process tree, ignore.
1771 return
1772 match = self.RE_RENAME.match(args)
1773 if not match:
1774 raise TracingFailure(
1775 'Failed to parse arguments: %s' % args,
1776 None, None, None)
1777 self._handle_file(pid, match.group(1))
1778 self._handle_file(pid, match.group(2))
1779
1780 def _handle_file(self, pid, filepath):
1781 if not filepath.startswith('/'):
1782 filepath = os.path.join(self._process_lookup[pid].cwd, filepath)
1783 # We can get '..' in the path.
1784 filepath = os.path.normpath(filepath)
1785 # Sadly, still need to filter out directories here;
1786 # saw open_nocancel(".", 0, 0) = 0 lines.
1787 if os.path.isdir(filepath):
1788 return
1789 self._process_lookup[pid].add_file(filepath, False)
1790
1791 def handle_ftruncate(self, pid, args):
1792 """Just used as a signal to kill dtrace, ignoring."""
1793 pass
1794
1795 @staticmethod
1796 def _handle_ignored(pid, args):
1797 """Is called for all the event traces that are not handled."""
1798 raise NotImplementedError('Please implement me')
1799
1800 @staticmethod
1801 def process_escaped_arguments(text):
1802 """Extracts escaped arguments on a string and return the arguments as a
1803 list.
1804
1805 Implemented as an automaton.
1806
1807 Example:
1808 With text = '\\001python2.7\\001-c\\001print(\\"hi\\")\\0', the
1809 function will return ['python2.7', '-c', 'print("hi")]
1810 """
1811 if not text.endswith('\\0'):
1812 raise ValueError('String is not null terminated: %r' % text, text)
1813 text = text[:-2]
1814
1815 def unescape(x):
1816 """Replaces '\\' with '\' and '\?' (where ? is anything) with ?."""
1817 out = []
1818 escaped = False
1819 for i in x:
1820 if i == '\\' and not escaped:
1821 escaped = True
1822 continue
1823 escaped = False
1824 out.append(i)
1825 return ''.join(out)
1826
1827 return [unescape(i) for i in text.split('\\001')]
1828
1829 class Tracer(ApiBase.Tracer):
1830 # pylint: disable=C0301
1831 #
1832 # To understand the following code, you'll want to take a look at:
1833 # http://developers.sun.com/solaris/articles/dtrace_quickref/dtrace_quickref.html
1834 # https://wikis.oracle.com/display/DTrace/Variables
1835 # http://docs.oracle.com/cd/E19205-01/820-4221/
1836 #
1837 # 0. Dump all the valid probes into a text file. It is important, you
1838 # want to redirect into a file and you don't want to constantly 'sudo'.
1839 # $ sudo dtrace -l > probes.txt
1840 #
1841 # 1. Count the number of probes:
1842 # $ wc -l probes.txt
1843 # 81823 # On OSX 10.7, including 1 header line.
1844 #
1845 # 2. List providers, intentionally skipping all the 'syspolicy10925' and the
1846 # likes and skipping the header with NR>1:
1847 # $ awk 'NR>1 { print $2 }' probes.txt | sort | uniq | grep -v '[[:digit:]]'
1848 # dtrace
1849 # fbt
1850 # io
1851 # ip
1852 # lockstat
1853 # mach_trap
1854 # proc
1855 # profile
1856 # sched
1857 # syscall
1858 # tcp
1859 # vminfo
1860 #
1861 # 3. List of valid probes:
1862 # $ grep syscall probes.txt | less
1863 # or use dtrace directly:
1864 # $ sudo dtrace -l -P syscall | less
1865 #
1866 # trackedpid is an associative array where its value can be 0, 1 or 2.
1867 # 0 is for untracked processes and is the default value for items not
1868 # in the associative array.
1869 # 1 is for tracked processes.
1870 # 2 is for the script created by create_thunk() only. It is not tracked
1871 # itself but all its decendants are.
1872 #
1873 # The script will kill itself only once waiting_to_die == 1 and
1874 # current_processes == 0, so that both getlogin() was called and that
1875 # all traced processes exited.
1876 #
1877 # TODO(maruel): Use cacheable predicates. See
1878 # https://wikis.oracle.com/display/DTrace/Performance+Considerations
1879 D_CODE = """
1880 dtrace:::BEGIN {
1881 waiting_to_die = 0;
1882 current_processes = 0;
1883 logindex = 0;
1884 printf("%d %d %s_%s(\\"%s\\")\\n",
1885 logindex, PID, probeprov, probename, SCRIPT);
1886 logindex++;
1887 }
1888
1889 proc:::start /trackedpid[ppid]/ {
1890 trackedpid[pid] = 1;
1891 current_processes += 1;
1892 printf("%d %d %s_%s(%d, \\"%s\\", %d)\\n",
1893 logindex, pid, probeprov, probename,
1894 ppid,
1895 execname,
1896 current_processes);
1897 logindex++;
1898 }
1899 /* Should use SCRIPT but there is no access to this variable at that
1900 * point. */
1901 proc:::start /ppid == PID && execname == "Python"/ {
1902 trackedpid[pid] = 2;
1903 current_processes += 1;
1904 printf("%d %d %s_%s(%d, \\"%s\\", %d)\\n",
1905 logindex, pid, probeprov, probename,
1906 ppid,
1907 execname,
1908 current_processes);
1909 logindex++;
1910 }
1911 proc:::exit /trackedpid[pid] &&
1912 current_processes == 1 &&
1913 waiting_to_die == 1/ {
1914 trackedpid[pid] = 0;
1915 current_processes -= 1;
1916 printf("%d %d %s_%s(%d)\\n",
1917 logindex, pid, probeprov, probename,
1918 current_processes);
1919 logindex++;
1920 exit(0);
1921 }
1922 proc:::exit /trackedpid[pid]/ {
1923 trackedpid[pid] = 0;
1924 current_processes -= 1;
1925 printf("%d %d %s_%s(%d)\\n",
1926 logindex, pid, probeprov, probename,
1927 current_processes);
1928 logindex++;
1929 }
1930
1931 /* Use an arcane function to detect when we need to die */
1932 syscall::ftruncate:entry /pid == PID && arg0 == FILE_ID/ {
1933 waiting_to_die = 1;
1934 printf("%d %d %s()\\n", logindex, pid, probefunc);
1935 logindex++;
1936 }
1937 syscall::ftruncate:entry /
1938 pid == PID && arg0 == FILE_ID && current_processes == 0/ {
1939 exit(0);
1940 }
1941
1942 syscall::open*:entry /trackedpid[pid] == 1/ {
1943 self->open_arg0 = arg0;
1944 self->open_arg1 = arg1;
1945 self->open_arg2 = arg2;
1946 }
1947 syscall::open*:return /trackedpid[pid] == 1 && errno == 0/ {
1948 this->open_arg0 = copyinstr(self->open_arg0);
1949 printf("%d %d %s(\\"%s\\", 0x%x, 0x%x)\\n",
1950 logindex, pid, probefunc,
1951 this->open_arg0,
1952 self->open_arg1,
1953 self->open_arg2);
1954 logindex++;
1955 this->open_arg0 = 0;
1956 }
1957 syscall::open*:return /trackedpid[pid] == 1/ {
1958 self->open_arg0 = 0;
1959 self->open_arg1 = 0;
1960 self->open_arg2 = 0;
1961 }
1962
1963 syscall::rename:entry /trackedpid[pid] == 1/ {
1964 self->rename_arg0 = arg0;
1965 self->rename_arg1 = arg1;
1966 }
1967 syscall::rename:return /trackedpid[pid] == 1 && errno == 0/ {
1968 this->rename_arg0 = copyinstr(self->rename_arg0);
1969 this->rename_arg1 = copyinstr(self->rename_arg1);
1970 printf("%d %d %s(\\"%s\\", \\"%s\\")\\n",
1971 logindex, pid, probefunc,
1972 this->rename_arg0,
1973 this->rename_arg1);
1974 logindex++;
1975 this->rename_arg0 = 0;
1976 this->rename_arg1 = 0;
1977 }
1978 syscall::rename:return /trackedpid[pid] == 1/ {
1979 self->rename_arg0 = 0;
1980 self->rename_arg1 = 0;
1981 }
1982
1983 /* Track chdir, it's painful because it is only receiving relative path.
1984 */
1985 syscall::chdir:entry /trackedpid[pid] == 1/ {
1986 self->chdir_arg0 = arg0;
1987 }
1988 syscall::chdir:return /trackedpid[pid] == 1 && errno == 0/ {
1989 this->chdir_arg0 = copyinstr(self->chdir_arg0);
1990 printf("%d %d %s(\\"%s\\")\\n",
1991 logindex, pid, probefunc,
1992 this->chdir_arg0);
1993 logindex++;
1994 this->chdir_arg0 = 0;
1995 }
1996 syscall::chdir:return /trackedpid[pid] == 1/ {
1997 self->chdir_arg0 = 0;
1998 }
1999 """
2000
2001 # execve-specific code, tends to throw a lot of exceptions.
2002 D_CODE_EXECVE = """
2003 /* Finally what we care about! */
2004 syscall::exec*:entry /trackedpid[pid]/ {
2005 self->exec_arg0 = copyinstr(arg0);
2006 /* Incrementally probe for a NULL in the argv parameter of execve() to
2007 * figure out argc. */
2008 /* TODO(maruel): Skip the remaining copyin() when a NULL pointer was
2009 * found. */
2010 self->exec_argc = 0;
2011 /* Probe for argc==1 */
2012 this->exec_argv = (user_addr_t*)copyin(
2013 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2014 self->exec_argc = this->exec_argv[self->exec_argc] ?
2015 (self->exec_argc + 1) : self->exec_argc;
2016
2017 /* Probe for argc==2 */
2018 this->exec_argv = (user_addr_t*)copyin(
2019 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2020 self->exec_argc = this->exec_argv[self->exec_argc] ?
2021 (self->exec_argc + 1) : self->exec_argc;
2022
2023 /* Probe for argc==3 */
2024 this->exec_argv = (user_addr_t*)copyin(
2025 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2026 self->exec_argc = this->exec_argv[self->exec_argc] ?
2027 (self->exec_argc + 1) : self->exec_argc;
2028
2029 /* Probe for argc==4 */
2030 this->exec_argv = (user_addr_t*)copyin(
2031 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2032 self->exec_argc = this->exec_argv[self->exec_argc] ?
2033 (self->exec_argc + 1) : self->exec_argc;
2034
2035 /* Copy the inputs strings since there is no guarantee they'll be
2036 * present after the call completed. */
2037 self->exec_argv0 = (self->exec_argc > 0) ?
2038 copyinstr(this->exec_argv[0]) : "";
2039 self->exec_argv1 = (self->exec_argc > 1) ?
2040 copyinstr(this->exec_argv[1]) : "";
2041 self->exec_argv2 = (self->exec_argc > 2) ?
2042 copyinstr(this->exec_argv[2]) : "";
2043 self->exec_argv3 = (self->exec_argc > 3) ?
2044 copyinstr(this->exec_argv[3]) : "";
2045 this->exec_argv = 0;
2046 }
2047 syscall::exec*:return /trackedpid[pid] && errno == 0/ {
2048 /* We need to join strings here, as using multiple printf() would
2049 * cause tearing when multiple threads/processes are traced.
2050 * Since it is impossible to escape a string and join it to another one,
2051 * like sprintf("%s%S", previous, more), use hackery.
2052 * Each of the elements are split with a \\1. \\0 cannot be used because
2053 * it is simply ignored. This will conflict with any program putting a
2054 * \\1 in their execve() string but this should be "rare enough" */
2055 this->args = "";
2056 /* Process exec_argv[0] */
2057 this->args = strjoin(
2058 this->args, (self->exec_argc > 0) ? self->exec_argv0 : "");
2059
2060 /* Process exec_argv[1] */
2061 this->args = strjoin(
2062 this->args, (self->exec_argc > 1) ? "\\1" : "");
2063 this->args = strjoin(
2064 this->args, (self->exec_argc > 1) ? self->exec_argv1 : "");
2065
2066 /* Process exec_argv[2] */
2067 this->args = strjoin(
2068 this->args, (self->exec_argc > 2) ? "\\1" : "");
2069 this->args = strjoin(
2070 this->args, (self->exec_argc > 2) ? self->exec_argv2 : "");
2071
2072 /* Process exec_argv[3] */
2073 this->args = strjoin(
2074 this->args, (self->exec_argc > 3) ? "\\1" : "");
2075 this->args = strjoin(
2076 this->args, (self->exec_argc > 3) ? self->exec_argv3 : "");
2077
2078 /* Prints self->exec_argc to permits verifying the internal
2079 * consistency since this code is quite fishy. */
2080 printf("%d %d %s(\\"%s\\", [%d, %S])\\n",
2081 logindex, pid, probefunc,
2082 self->exec_arg0,
2083 self->exec_argc,
2084 this->args);
2085 logindex++;
2086 this->args = 0;
2087 }
2088 syscall::exec*:return /trackedpid[pid]/ {
2089 self->exec_arg0 = 0;
2090 self->exec_argc = 0;
2091 self->exec_argv0 = 0;
2092 self->exec_argv1 = 0;
2093 self->exec_argv2 = 0;
2094 self->exec_argv3 = 0;
2095 }
2096 """
2097
2098 # Code currently not used.
2099 D_EXTRANEOUS = """
2100 /* This is a good learning experience, since it traces a lot of things
2101 * related to the process and child processes.
2102 * Warning: it generates a gigantic log. For example, tracing
2103 * "data/trace_inputs/child1.py --child" generates a 2mb log and takes
2104 * several minutes to execute.
2105 */
2106 /*
2107 mach_trap::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2108 printf("%d %d %s_%s() = %d\\n",
2109 logindex, pid, probeprov, probefunc, errno);
2110 logindex++;
2111 }
2112 proc::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2113 printf("%d %d %s_%s() = %d\\n",
2114 logindex, pid, probeprov, probefunc, errno);
2115 logindex++;
2116 }
2117 sched::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2118 printf("%d %d %s_%s() = %d\\n",
2119 logindex, pid, probeprov, probefunc, errno);
2120 logindex++;
2121 }
2122 syscall::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2123 printf("%d %d %s_%s() = %d\\n",
2124 logindex, pid, probeprov, probefunc, errno);
2125 logindex++;
2126 }
2127 vminfo::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2128 printf("%d %d %s_%s() = %d\\n",
2129 logindex, pid, probeprov, probefunc, errno);
2130 logindex++;
2131 }
2132 */
2133 /* TODO(maruel): *stat* functions and friends
2134 syscall::access:return,
2135 syscall::chdir:return,
2136 syscall::chflags:return,
2137 syscall::chown:return,
2138 syscall::chroot:return,
2139 syscall::getattrlist:return,
2140 syscall::getxattr:return,
2141 syscall::lchown:return,
2142 syscall::lstat64:return,
2143 syscall::lstat:return,
2144 syscall::mkdir:return,
2145 syscall::pathconf:return,
2146 syscall::readlink:return,
2147 syscall::removexattr:return,
2148 syscall::setxattr:return,
2149 syscall::stat64:return,
2150 syscall::stat:return,
2151 syscall::truncate:return,
2152 syscall::unlink:return,
2153 syscall::utimes:return,
2154 */
2155 """
2156
2157 def __init__(self, logname):
2158 """Starts the log collection with dtrace.
2159
2160 Requires root access or chmod 4555 on dtrace. dtrace is asynchronous so
2161 this needs to wait for dtrace to be "warmed up".
2162 """
2163 super(Dtrace.Tracer, self).__init__(logname)
2164 self._script = create_thunk()
2165 # This unique dummy temp file is used to signal the dtrace script that it
2166 # should stop as soon as all the child processes are done. A bit hackish
2167 # but works fine enough.
2168 self._dummy_file_id, self._dummy_file_name = tempfile.mkstemp(
2169 prefix='trace_signal_file')
2170
2171 # Note: do not use the -p flag. It's useless if the initial process quits
2172 # too fast, resulting in missing traces from the grand-children. The D
2173 # code manages the dtrace lifetime itself.
2174 trace_cmd = [
2175 'sudo',
2176 'dtrace',
2177 # Use a larger buffer if getting 'out of scratch space' errors.
2178 # Ref: https://wikis.oracle.com/display/DTrace/Options+and+Tunables
2179 '-b', '10m',
2180 '-x', 'dynvarsize=10m',
2181 #'-x', 'dtrace_global_maxsize=1m',
2182 '-x', 'evaltime=exec',
2183 '-o', '/dev/stderr',
2184 '-q',
2185 '-n', self._get_dtrace_code(),
2186 ]
2187 with open(self._logname + '.log', 'wb') as logfile:
2188 self._dtrace = subprocess.Popen(
2189 trace_cmd, stdout=logfile, stderr=subprocess.STDOUT)
2190 logging.debug('Started dtrace pid: %d' % self._dtrace.pid)
2191
2192 # Reads until one line is printed, which signifies dtrace is up and ready.
2193 with open(self._logname + '.log', 'rb') as logfile:
2194 while 'dtrace_BEGIN' not in logfile.readline():
2195 if self._dtrace.poll() is not None:
2196 # Do a busy wait. :/
2197 break
2198 logging.debug('dtrace started')
2199
2200 def _get_dtrace_code(self):
2201 """Setups the D code to implement child process tracking.
2202
2203 Injects the cookie in the script so it knows when to stop.
2204
2205 The script will detect any instance of the script created with
2206 create_thunk() and will start tracing it.
2207 """
2208 return (
2209 'inline int PID = %d;\n'
2210 'inline string SCRIPT = "%s";\n'
2211 'inline int FILE_ID = %d;\n'
2212 '\n'
2213 '%s') % (
2214 os.getpid(),
2215 self._script,
2216 self._dummy_file_id,
2217 self.D_CODE) + self.D_CODE_EXECVE
2218
2219 def trace(self, cmd, cwd, tracename, output):
2220 """Runs dtrace on an executable.
2221
2222 This dtruss is broken when it starts the process itself or when tracing
2223 child processes, this code starts a wrapper process
2224 generated with create_thunk() which starts the executable to trace.
2225 """
2226 logging.info('trace(%s, %s, %s, %s)' % (cmd, cwd, tracename, output))
2227 assert os.path.isabs(cmd[0]), cmd[0]
2228 assert os.path.isabs(cwd), cwd
2229 assert os.path.normpath(cwd) == cwd, cwd
2230 with self._lock:
2231 if not self._initialized:
2232 raise TracingFailure(
2233 'Called Tracer.trace() on an unitialized object',
2234 None, None, None, tracename)
2235 assert tracename not in (i['trace'] for i in self._traces)
2236
2237 # Starts the script wrapper to start the child process. This signals the
2238 # dtrace script that this process is to be traced.
2239 stdout = stderr = None
2240 if output:
2241 stdout = subprocess.PIPE
2242 stderr = subprocess.STDOUT
2243 child_cmd = [
2244 sys.executable,
2245 self._script,
2246 tracename,
2247 ]
2248 # Call a dummy function so that dtrace knows I'm about to launch a process
2249 # that needs to be traced.
2250 # Yummy.
2251 child = subprocess.Popen(
2252 child_cmd + fix_python_path(cmd),
2253 stdin=subprocess.PIPE,
2254 stdout=stdout,
2255 stderr=stderr,
2256 cwd=cwd)
2257 logging.debug('Started child pid: %d' % child.pid)
2258
2259 out = child.communicate()[0]
2260 # This doesn't mean tracing is done, one of the grand-child process may
2261 # still be alive. It will be tracked with the dtrace script.
2262
2263 with self._lock:
2264 assert tracename not in (i['trace'] for i in self._traces)
2265 self._traces.append(
2266 {
2267 'cmd': cmd,
2268 'cwd': cwd,
2269 # The pid of strace process, not very useful.
2270 'pid': child.pid,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002271 'output': out,
maruel@chromium.org964ddde2012-10-29 18:13:46 +00002272 'trace': tracename,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002273 })
2274 return child.returncode, out
2275
2276 def close(self, timeout=None):
2277 """Terminates dtrace."""
2278 logging.debug('close(%s)' % timeout)
2279 try:
2280 try:
2281 super(Dtrace.Tracer, self).close(timeout)
2282 # Signal dtrace that it should stop now.
maruel@chromium.orgf43e68b2012-10-15 20:23:10 +00002283 # ftruncate doesn't exist on Windows.
2284 os.ftruncate(self._dummy_file_id, 0) # pylint: disable=E1101
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002285 if timeout:
2286 start = time.time()
2287 # Use polling. :/
2288 while (self._dtrace.poll() is None and
2289 (time.time() - start) < timeout):
2290 time.sleep(0.1)
2291 self._dtrace.kill()
2292 self._dtrace.wait()
2293 finally:
2294 # Make sure to kill it in any case.
2295 if self._dtrace.poll() is None:
2296 try:
2297 self._dtrace.kill()
2298 self._dtrace.wait()
2299 except OSError:
2300 pass
2301
2302 if self._dtrace.returncode != 0:
2303 # Warn about any dtrace failure but basically ignore it.
2304 print 'dtrace failure: %s' % self._dtrace.returncode
2305 finally:
2306 os.close(self._dummy_file_id)
2307 os.remove(self._dummy_file_name)
2308 os.remove(self._script)
2309
2310 def post_process_log(self):
2311 """Sorts the log back in order when each call occured.
2312
2313 dtrace doesn't save the buffer in strict order since it keeps one buffer
2314 per CPU.
2315 """
2316 super(Dtrace.Tracer, self).post_process_log()
2317 logname = self._logname + '.log'
2318 with open(logname, 'rb') as logfile:
2319 lines = [l for l in logfile if l.strip()]
2320 errors = [l for l in lines if l.startswith('dtrace:')]
2321 if errors:
2322 raise TracingFailure(
2323 'Found errors in the trace: %s' % '\n'.join(errors),
2324 None, None, None, logname)
2325 try:
2326 lines = sorted(lines, key=lambda l: int(l.split(' ', 1)[0]))
2327 except ValueError:
2328 raise TracingFailure(
2329 'Found errors in the trace: %s' % '\n'.join(
2330 l for l in lines if l.split(' ', 1)[0].isdigit()),
2331 None, None, None, logname)
2332 with open(logname, 'wb') as logfile:
2333 logfile.write(''.join(lines))
2334
2335 @staticmethod
2336 def clean_trace(logname):
2337 for ext in ('', '.log'):
2338 if os.path.isfile(logname + ext):
2339 os.remove(logname + ext)
2340
2341 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00002342 def parse_log(cls, logname, blacklist, trace_name):
2343 logging.info('parse_log(%s, ..., %s)', logname, trace_name)
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00002344 assert os.path.isabs(logname)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002345
2346 def blacklist_more(filepath):
2347 # All the HFS metadata is in the form /.vol/...
2348 return blacklist(filepath) or re.match(r'^\/\.vol\/.+$', filepath)
2349
2350 data = read_json(logname)
2351 out = []
2352 for item in data['traces']:
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00002353 if trace_name and item['trace'] != trace_name:
2354 continue
maruel@chromium.org964ddde2012-10-29 18:13:46 +00002355 result = {
2356 'output': item['output'],
2357 'trace': item['trace'],
2358 }
2359 try:
2360 context = cls.Context(blacklist_more, item['pid'], item['cwd'])
2361 for line in open(logname + '.log', 'rb'):
2362 context.on_line(line)
2363 result['results'] = context.to_results()
2364 except TracingFailure:
2365 result['exception'] = sys.exc_info()
2366 out.append(result)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002367 return out
2368
2369
2370class LogmanTrace(ApiBase):
2371 """Uses the native Windows ETW based tracing functionality to trace a child
2372 process.
2373
2374 Caveat: this implementations doesn't track cwd or initial_cwd. It is because
2375 the Windows Kernel doesn't have a concept of 'current working directory' at
2376 all. A Win32 process has a map of current directories, one per drive letter
2377 and it is managed by the user mode kernel32.dll. In kernel, a file is always
2378 opened relative to another file_object or as an absolute path. All the current
2379 working directory logic is done in user mode.
2380 """
2381 class Context(ApiBase.Context):
2382 """Processes a ETW log line and keeps the list of existent and non
2383 existent files accessed.
2384
2385 Ignores directories.
2386 """
2387 # These indexes are for the stripped version in json.
2388 EVENT_NAME = 0
2389 TYPE = 1
2390 PID = 2
2391 TID = 3
2392 PROCESSOR_ID = 4
2393 TIMESTAMP = 5
2394 USER_DATA = 6
2395
2396 class Process(ApiBase.Context.Process):
2397 def __init__(self, *args):
2398 super(LogmanTrace.Context.Process, self).__init__(*args)
2399 # Handle file objects that succeeded.
2400 self.file_objects = {}
2401
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002402 def __init__(self, blacklist, thunk_pid, trace_name, thunk_cmd):
2403 logging.info(
2404 '%s(%d, %s, %s)', self.__class__.__name__, thunk_pid, trace_name,
2405 thunk_cmd)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002406 super(LogmanTrace.Context, self).__init__(blacklist)
2407 self._drive_map = DosDriveMap()
2408 # Threads mapping to the corresponding process id.
2409 self._threads_active = {}
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002410 # Process ID of the tracer, e.g. the temporary script created by
2411 # create_thunk(). This is tricky because the process id may have been
2412 # reused.
2413 self._thunk_pid = thunk_pid
2414 self._thunk_cmd = thunk_cmd
2415 self._trace_name = trace_name
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002416 self._line_number = 0
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002417 self._thunk_process = None
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002418
2419 def on_line(self, line):
2420 """Processes a json Event line."""
2421 self._line_number += 1
2422 try:
2423 # By Opcode
2424 handler = getattr(
2425 self,
2426 'handle_%s_%s' % (line[self.EVENT_NAME], line[self.TYPE]),
2427 None)
2428 if not handler:
2429 raise TracingFailure(
2430 'Unexpected event %s_%s' % (
2431 line[self.EVENT_NAME], line[self.TYPE]),
2432 None, None, None)
2433 handler(line)
2434 except TracingFailure, e:
2435 # Hack in the values since the handler could be a static function.
2436 e.pid = line[self.PID]
2437 e.line = line
2438 e.line_number = self._line_number
2439 # Re-raise the modified exception.
2440 raise
2441 except (KeyError, NotImplementedError, ValueError), e:
2442 raise TracingFailure(
2443 'Trace generated a %s exception: %s' % (
2444 e.__class__.__name__, str(e)),
2445 line[self.PID],
2446 self._line_number,
2447 line,
2448 e)
2449
2450 def to_results(self):
2451 if not self.root_process:
2452 raise TracingFailure(
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002453 'Failed to detect the initial process %d' % self._thunk_pid,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002454 None, None, None)
2455 process = self.root_process.to_results_process()
2456 return Results(process)
2457
2458 def _thread_to_process(self, tid):
2459 """Finds the process from the thread id."""
2460 tid = int(tid, 16)
2461 pid = self._threads_active.get(tid)
2462 if not pid or not self._process_lookup.get(pid):
2463 return
2464 return self._process_lookup[pid]
2465
2466 @classmethod
2467 def handle_EventTrace_Header(cls, line):
2468 """Verifies no event was dropped, e.g. no buffer overrun occured."""
2469 BUFFER_SIZE = cls.USER_DATA
2470 #VERSION = cls.USER_DATA + 1
2471 #PROVIDER_VERSION = cls.USER_DATA + 2
2472 #NUMBER_OF_PROCESSORS = cls.USER_DATA + 3
2473 #END_TIME = cls.USER_DATA + 4
2474 #TIMER_RESOLUTION = cls.USER_DATA + 5
2475 #MAX_FILE_SIZE = cls.USER_DATA + 6
2476 #LOG_FILE_MODE = cls.USER_DATA + 7
2477 #BUFFERS_WRITTEN = cls.USER_DATA + 8
2478 #START_BUFFERS = cls.USER_DATA + 9
2479 #POINTER_SIZE = cls.USER_DATA + 10
2480 EVENTS_LOST = cls.USER_DATA + 11
2481 #CPU_SPEED = cls.USER_DATA + 12
2482 #LOGGER_NAME = cls.USER_DATA + 13
2483 #LOG_FILE_NAME = cls.USER_DATA + 14
2484 #BOOT_TIME = cls.USER_DATA + 15
2485 #PERF_FREQ = cls.USER_DATA + 16
2486 #START_TIME = cls.USER_DATA + 17
2487 #RESERVED_FLAGS = cls.USER_DATA + 18
2488 #BUFFERS_LOST = cls.USER_DATA + 19
2489 #SESSION_NAME_STRING = cls.USER_DATA + 20
2490 #LOG_FILE_NAME_STRING = cls.USER_DATA + 21
2491 if line[EVENTS_LOST] != '0':
2492 raise TracingFailure(
2493 ( '%s events were lost during trace, please increase the buffer '
2494 'size from %s') % (line[EVENTS_LOST], line[BUFFER_SIZE]),
2495 None, None, None)
2496
2497 def handle_FileIo_Cleanup(self, line):
2498 """General wisdom: if a file is closed, it's because it was opened.
2499
2500 Note that FileIo_Close is not used since if a file was opened properly but
2501 not closed before the process exits, only Cleanup will be logged.
2502 """
2503 #IRP = self.USER_DATA
2504 TTID = self.USER_DATA + 1 # Thread ID, that's what we want.
2505 FILE_OBJECT = self.USER_DATA + 2
2506 #FILE_KEY = self.USER_DATA + 3
2507 proc = self._thread_to_process(line[TTID])
2508 if not proc:
2509 # Not a process we care about.
2510 return
2511 file_object = line[FILE_OBJECT]
2512 if file_object in proc.file_objects:
2513 proc.add_file(proc.file_objects.pop(file_object), False)
2514
2515 def handle_FileIo_Create(self, line):
2516 """Handles a file open.
2517
2518 All FileIo events are described at
2519 http://msdn.microsoft.com/library/windows/desktop/aa363884.aspx
2520 for some value of 'description'.
2521
2522 " (..) process and thread id values of the IO events (..) are not valid "
2523 http://msdn.microsoft.com/magazine/ee358703.aspx
2524
2525 The FileIo.Create event doesn't return if the CreateFile() call
2526 succeeded, so keep track of the file_object and check that it is
2527 eventually closed with FileIo_Cleanup.
2528 """
2529 #IRP = self.USER_DATA
2530 TTID = self.USER_DATA + 1 # Thread ID, that's what we want.
2531 FILE_OBJECT = self.USER_DATA + 2
2532 #CREATE_OPTIONS = self.USER_DATA + 3
2533 #FILE_ATTRIBUTES = self.USER_DATA + 4
2534 #self.USER_DATA + SHARE_ACCESS = 5
2535 OPEN_PATH = self.USER_DATA + 6
2536
2537 proc = self._thread_to_process(line[TTID])
2538 if not proc:
2539 # Not a process we care about.
2540 return
2541
2542 match = re.match(r'^\"(.+)\"$', line[OPEN_PATH])
2543 raw_path = match.group(1)
2544 # Ignore directories and bare drive right away.
2545 if raw_path.endswith(os.path.sep):
2546 return
2547 filepath = self._drive_map.to_win32(raw_path)
2548 # Ignore bare drive right away. Some may still fall through with format
2549 # like '\\?\X:'
2550 if len(filepath) == 2:
2551 return
2552 file_object = line[FILE_OBJECT]
2553 if os.path.isdir(filepath):
2554 # There is no O_DIRECTORY equivalent on Windows. The closed is
2555 # FILE_FLAG_BACKUP_SEMANTICS but it's not exactly right either. So
2556 # simply discard directories are they are found.
2557 return
2558 # Override any stale file object
2559 proc.file_objects[file_object] = filepath
2560
2561 def handle_FileIo_Rename(self, line):
2562 # TODO(maruel): Handle?
2563 pass
2564
2565 def handle_Process_End(self, line):
2566 pid = line[self.PID]
2567 if self._process_lookup.get(pid):
2568 logging.info('Terminated: %d' % pid)
2569 self._process_lookup[pid] = None
2570 else:
2571 logging.debug('Terminated: %d' % pid)
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002572 if self._thunk_process and self._thunk_process.pid == pid:
2573 self._thunk_process = None
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002574
2575 def handle_Process_Start(self, line):
2576 """Handles a new child process started by PID."""
2577 #UNIQUE_PROCESS_KEY = self.USER_DATA
2578 PROCESS_ID = self.USER_DATA + 1
2579 #PARENT_PID = self.USER_DATA + 2
2580 #SESSION_ID = self.USER_DATA + 3
2581 #EXIT_STATUS = self.USER_DATA + 4
2582 #DIRECTORY_TABLE_BASE = self.USER_DATA + 5
2583 #USER_SID = self.USER_DATA + 6
2584 IMAGE_FILE_NAME = self.USER_DATA + 7
2585 COMMAND_LINE = self.USER_DATA + 8
2586
2587 ppid = line[self.PID]
2588 pid = int(line[PROCESS_ID], 16)
2589 logging.debug(
2590 'New process %d->%d (%s) %s' %
2591 (ppid, pid, line[IMAGE_FILE_NAME], line[COMMAND_LINE]))
2592
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002593 command_line = None
2594 def get_command_line():
2595 # TODO(maruel): Process escapes.
2596 if (not line[COMMAND_LINE].startswith('"') or
2597 not line[COMMAND_LINE].endswith('"')):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002598 raise TracingFailure(
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002599 'Command line is not properly quoted: %s' % line[COMMAND_LINE],
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002600 None, None, None)
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002601 return CommandLineToArgvW(line[COMMAND_LINE][1:-1])
2602
2603 if pid == self._thunk_pid:
2604 # Need to ignore processes we don't know about because the log is
2605 # system-wide. self._thunk_pid shall start only one process.
2606 # This is tricky though because Windows *loves* to reuse process id and
2607 # it happens often that the process ID of the thunk script created by
2608 # create_thunk() is reused. So just detecting the pid here is not
2609 # sufficient, we must confirm the command line.
2610 command_line = get_command_line()
2611 if command_line[:len(self._thunk_cmd)] != self._thunk_cmd:
2612 logging.info(
2613 'Ignoring duplicate pid %d for %s: %s while searching for %s',
2614 pid, self._trace_name, command_line, self._thunk_cmd)
2615 return
2616
2617 # TODO(maruel): The check is quite weak. Add the thunk path.
2618 if self._thunk_process:
2619 raise TracingFailure(
2620 ( 'Parent process is _thunk_pid(%d) but thunk_process(%d) is '
2621 'already set') % (self._thunk_pid, self._thunk_process.pid),
2622 None, None, None)
2623 proc = self.Process(self.blacklist, pid, None)
2624 self._thunk_process = proc
2625 return
2626 elif ppid == self._thunk_pid and self._thunk_process:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002627 proc = self.Process(self.blacklist, pid, None)
2628 self.root_process = proc
2629 ppid = None
2630 elif self._process_lookup.get(ppid):
2631 proc = self.Process(self.blacklist, pid, None)
2632 self._process_lookup[ppid].children.append(proc)
2633 else:
2634 # Ignore
2635 return
2636 self._process_lookup[pid] = proc
2637
2638 if (not line[IMAGE_FILE_NAME].startswith('"') or
2639 not line[IMAGE_FILE_NAME].endswith('"')):
2640 raise TracingFailure(
2641 'Command line is not properly quoted: %s' % line[IMAGE_FILE_NAME],
2642 None, None, None)
2643
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002644 proc.command = command_line or get_command_line()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002645 proc.executable = line[IMAGE_FILE_NAME][1:-1]
2646 # proc.command[0] may be the absolute path of 'executable' but it may be
2647 # anything else too. If it happens that command[0] ends with executable,
2648 # use it, otherwise defaults to the base name.
2649 cmd0 = proc.command[0].lower()
2650 if not cmd0.endswith('.exe'):
2651 # TODO(maruel): That's not strictly true either.
2652 cmd0 += '.exe'
2653 if cmd0.endswith(proc.executable) and os.path.isfile(cmd0):
2654 # Fix the path.
2655 cmd0 = cmd0.replace('/', os.path.sep)
2656 cmd0 = os.path.normpath(cmd0)
2657 proc.executable = get_native_path_case(cmd0)
2658 logging.info(
2659 'New child: %s -> %d %s' % (ppid, pid, proc.executable))
2660
2661 def handle_Thread_End(self, line):
2662 """Has the same parameters as Thread_Start."""
2663 tid = int(line[self.TID], 16)
2664 self._threads_active.pop(tid, None)
2665
2666 def handle_Thread_Start(self, line):
2667 """Handles a new thread created.
2668
2669 Do not use self.PID here since a process' initial thread is created by
2670 the parent process.
2671 """
2672 PROCESS_ID = self.USER_DATA
2673 TTHREAD_ID = self.USER_DATA + 1
2674 #STACK_BASE = self.USER_DATA + 2
2675 #STACK_LIMIT = self.USER_DATA + 3
2676 #USER_STACK_BASE = self.USER_DATA + 4
2677 #USER_STACK_LIMIT = self.USER_DATA + 5
2678 #AFFINITY = self.USER_DATA + 6
2679 #WIN32_START_ADDR = self.USER_DATA + 7
2680 #TEB_BASE = self.USER_DATA + 8
2681 #SUB_PROCESS_TAG = self.USER_DATA + 9
2682 #BASE_PRIORITY = self.USER_DATA + 10
2683 #PAGE_PRIORITY = self.USER_DATA + 11
2684 #IO_PRIORITY = self.USER_DATA + 12
2685 #THREAD_FLAGS = self.USER_DATA + 13
2686 # Do not use self.PID here since a process' initial thread is created by
2687 # the parent process.
2688 pid = int(line[PROCESS_ID], 16)
2689 tid = int(line[TTHREAD_ID], 16)
2690 logging.debug('New thread pid:%d, tid:%d' % (pid, tid))
2691 self._threads_active[tid] = pid
2692
2693 @classmethod
2694 def supported_events(cls):
2695 """Returns all the procesed events."""
2696 out = []
2697 for member in dir(cls):
2698 match = re.match(r'^handle_([A-Za-z]+)_([A-Za-z]+)$', member)
2699 if match:
2700 out.append(match.groups())
2701 return out
2702
2703 class Tracer(ApiBase.Tracer):
2704 # The basic headers.
2705 EXPECTED_HEADER = [
2706 u'Event Name',
2707 u'Type',
2708 u'Event ID',
2709 u'Version',
2710 u'Channel',
2711 u'Level', # 5
2712 u'Opcode',
2713 u'Task',
2714 u'Keyword',
2715 u'PID',
2716 u'TID', # 10
2717 u'Processor Number',
2718 u'Instance ID',
2719 u'Parent Instance ID',
2720 u'Activity ID',
2721 u'Related Activity ID', # 15
2722 u'Clock-Time',
2723 u'Kernel(ms)', # Both have a resolution of ~15ms which makes them
2724 u'User(ms)', # pretty much useless.
2725 u'User Data', # Extra arguments that are event-specific.
2726 ]
2727 # Only the useful headers common to all entries are listed there. Any column
2728 # at 19 or higher is dependent on the specific event.
2729 EVENT_NAME = 0
2730 TYPE = 1
2731 PID = 9
2732 TID = 10
2733 PROCESSOR_ID = 11
2734 TIMESTAMP = 16
2735 NULL_GUID = '{00000000-0000-0000-0000-000000000000}'
2736 USER_DATA = 19
2737
2738 def __init__(self, logname):
2739 """Starts the log collection.
2740
2741 Requires administrative access. logman.exe is synchronous so no need for a
2742 "warmup" call. 'Windows Kernel Trace' is *localized* so use its GUID
2743 instead. The GUID constant name is SystemTraceControlGuid. Lovely.
2744
2745 One can get the list of potentially interesting providers with:
2746 "logman query providers | findstr /i file"
2747 """
2748 super(LogmanTrace.Tracer, self).__init__(logname)
2749 self._script = create_thunk()
2750 cmd_start = [
2751 'logman.exe',
2752 'start',
2753 'NT Kernel Logger',
2754 '-p', '{9e814aad-3204-11d2-9a82-006008a86939}',
2755 # splitio,fileiocompletion,syscall,file,cswitch,img
2756 '(process,fileio,thread)',
2757 '-o', self._logname + '.etl',
2758 '-ets', # Send directly to kernel
2759 # Values extracted out of thin air.
2760 # Event Trace Session buffer size in kb.
2761 '-bs', '10240',
2762 # Number of Event Trace Session buffers.
2763 '-nb', '16', '256',
2764 ]
2765 logging.debug('Running: %s' % cmd_start)
2766 try:
2767 subprocess.check_call(
2768 cmd_start,
2769 stdin=subprocess.PIPE,
2770 stdout=subprocess.PIPE,
2771 stderr=subprocess.STDOUT)
2772 except subprocess.CalledProcessError, e:
2773 if e.returncode == -2147024891:
2774 print >> sys.stderr, 'Please restart with an elevated admin prompt'
2775 elif e.returncode == -2144337737:
2776 print >> sys.stderr, (
2777 'A kernel trace was already running, stop it and try again')
2778 raise
2779
2780 def trace(self, cmd, cwd, tracename, output):
2781 logging.info('trace(%s, %s, %s, %s)' % (cmd, cwd, tracename, output))
2782 assert os.path.isabs(cmd[0]), cmd[0]
2783 assert os.path.isabs(cwd), cwd
2784 assert os.path.normpath(cwd) == cwd, cwd
2785 with self._lock:
2786 if not self._initialized:
2787 raise TracingFailure(
2788 'Called Tracer.trace() on an unitialized object',
2789 None, None, None, tracename)
2790 assert tracename not in (i['trace'] for i in self._traces)
2791
2792 # Use "logman -?" for help.
2793
2794 stdout = stderr = None
2795 if output:
2796 stdout = subprocess.PIPE
2797 stderr = subprocess.STDOUT
2798
2799 # Run the child process.
2800 logging.debug('Running: %s' % cmd)
2801 # Use the temporary script generated with create_thunk() so we have a
2802 # clear pid owner. Since trace_inputs.py can be used as a library and
2803 # could trace multiple processes simultaneously, it makes it more complex
2804 # if the executable to be traced is executed directly here. It also solves
2805 # issues related to logman.exe that needs to be executed to control the
2806 # kernel trace.
2807 child_cmd = [
2808 sys.executable,
2809 self._script,
2810 tracename,
2811 ]
2812 child = subprocess.Popen(
2813 child_cmd + fix_python_path(cmd),
2814 cwd=cwd,
2815 stdin=subprocess.PIPE,
2816 stdout=stdout,
2817 stderr=stderr)
2818 logging.debug('Started child pid: %d' % child.pid)
2819 out = child.communicate()[0]
2820 # This doesn't mean all the grand-children are done. Sadly, we don't have
2821 # a good way to determine that.
2822
2823 with self._lock:
2824 assert tracename not in (i['trace'] for i in self._traces)
2825 self._traces.append({
maruel@chromium.orgf43e68b2012-10-15 20:23:10 +00002826 'cmd': cmd,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002827 'cwd': cwd,
maruel@chromium.org964ddde2012-10-29 18:13:46 +00002828 'output': out,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002829 'pid': child.pid,
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002830 # Used to figure out the real process when process ids are reused.
2831 'thunk_cmd': child_cmd,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002832 'trace': tracename,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002833 })
2834
2835 return child.returncode, out
2836
2837 def close(self, _timeout=None):
2838 """Stops the kernel log collection and converts the traces to text
2839 representation.
2840 """
2841 with self._lock:
2842 if not self._initialized:
2843 raise TracingFailure(
2844 'Called Tracer.close() on an unitialized object',
2845 None, None, None)
2846 os.remove(self._script)
2847 # Save metadata, add 'format' key..
2848 data = {
2849 'format': 'csv',
2850 'traces': self._traces,
2851 }
2852 write_json(self._logname, data, False)
2853
2854 cmd_stop = [
2855 'logman.exe',
2856 'stop',
2857 'NT Kernel Logger',
2858 '-ets', # Sends the command directly to the kernel.
2859 ]
2860 logging.debug('Running: %s' % cmd_stop)
2861 subprocess.check_call(
2862 cmd_stop,
2863 stdin=subprocess.PIPE,
2864 stdout=subprocess.PIPE,
2865 stderr=subprocess.STDOUT)
2866 self._initialized = False
2867
2868 def post_process_log(self):
2869 """Converts the .etl file into .csv then into .json."""
2870 super(LogmanTrace.Tracer, self).post_process_log()
2871 logformat = 'csv'
2872 self._convert_log(logformat)
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00002873 self._trim_log(logformat)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002874
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00002875 def _trim_log(self, logformat):
2876 """Reduces the amount of data in original log by generating a 'reduced'
2877 log.
2878 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002879 if logformat == 'csv_utf16':
2880 def load_file():
2881 def utf_8_encoder(unicode_csv_data):
2882 """Encodes the unicode object as utf-8 encoded str instance"""
2883 for line in unicode_csv_data:
2884 yield line.encode('utf-8')
2885
2886 def unicode_csv_reader(unicode_csv_data, **kwargs):
2887 """Encodes temporarily as UTF-8 since csv module doesn't do unicode.
2888 """
2889 csv_reader = csv.reader(utf_8_encoder(unicode_csv_data), **kwargs)
2890 for row in csv_reader:
2891 # Decode str utf-8 instances back to unicode instances, cell by
2892 # cell:
2893 yield [cell.decode('utf-8') for cell in row]
2894
2895 # The CSV file is UTF-16 so use codecs.open() to load the file into
2896 # the python internal unicode format (utf-8). Then explicitly
2897 # re-encode as utf8 as str instances so csv can parse it fine. Then
2898 # decode the utf-8 str back into python unicode instances. This
2899 # sounds about right.
2900 for line in unicode_csv_reader(
2901 codecs.open(self._logname + '.' + logformat, 'r', 'utf-16')):
2902 # line is a list of unicode objects
2903 # So much white space!
2904 yield [i.strip() for i in line]
2905
2906 elif logformat == 'csv':
2907 def load_file():
2908 def ansi_csv_reader(ansi_csv_data, **kwargs):
2909 """Loads an 'ANSI' code page and returns unicode() objects."""
2910 assert sys.getfilesystemencoding() == 'mbcs'
2911 encoding = get_current_encoding()
2912 for row in csv.reader(ansi_csv_data, **kwargs):
2913 # Decode str 'ansi' instances to unicode instances, cell by cell:
2914 yield [cell.decode(encoding) for cell in row]
2915
2916 # The fastest and smallest format but only supports 'ANSI' file paths.
2917 # E.g. the filenames are encoding in the 'current' encoding.
2918 for line in ansi_csv_reader(open(self._logname + '.' + logformat)):
2919 # line is a list of unicode objects.
2920 yield [i.strip() for i in line]
2921
2922 supported_events = LogmanTrace.Context.supported_events()
2923
2924 def trim(generator):
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00002925 """Loads items from the generator and returns the interesting data.
2926
2927 It filters out any uninteresting line and reduce the amount of data in
2928 the trace.
2929 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002930 for index, line in enumerate(generator):
2931 if not index:
2932 if line != self.EXPECTED_HEADER:
2933 raise TracingFailure(
2934 'Found malformed header: %s' % ' '.join(line),
2935 None, None, None)
2936 continue
2937 # As you can see, the CSV is full of useful non-redundant information:
2938 if (line[2] != '0' or # Event ID
2939 line[3] not in ('2', '3') or # Version
2940 line[4] != '0' or # Channel
2941 line[5] != '0' or # Level
2942 line[7] != '0' or # Task
2943 line[8] != '0x0000000000000000' or # Keyword
2944 line[12] != '' or # Instance ID
2945 line[13] != '' or # Parent Instance ID
2946 line[14] != self.NULL_GUID or # Activity ID
2947 line[15] != ''): # Related Activity ID
2948 raise TracingFailure(
2949 'Found unexpected values in line: %s' % ' '.join(line),
2950 None, None, None)
2951
2952 if (line[self.EVENT_NAME], line[self.TYPE]) not in supported_events:
2953 continue
2954
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002955 yield [
2956 line[self.EVENT_NAME],
2957 line[self.TYPE],
2958 line[self.PID],
2959 line[self.TID],
2960 line[self.PROCESSOR_ID],
2961 line[self.TIMESTAMP],
2962 ] + line[self.USER_DATA:]
2963
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00002964 # must not convert the trim() call into a list, since it will use too much
2965 # memory for large trace. use a csv file as a workaround since the json
2966 # parser requires a complete in-memory file.
2967 with open('%s.preprocessed' % self._logname, 'wb') as f:
2968 # $ and * can't be used in file name on windows, reducing the likelihood
2969 # of having to escape a string.
2970 out = csv.writer(
2971 f, delimiter='$', quotechar='*', quoting=csv.QUOTE_MINIMAL)
2972 for line in trim(load_file()):
2973 out.writerow([s.encode('utf-8') for s in line])
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002974
2975 def _convert_log(self, logformat):
2976 """Converts the ETL trace to text representation.
2977
2978 Normally, 'csv' is sufficient. If complex scripts are used (like eastern
2979 languages), use 'csv_utf16'. If localization gets in the way, use 'xml'.
2980
2981 Arguments:
2982 - logformat: Text format to be generated, csv, csv_utf16 or xml.
2983
2984 Use "tracerpt -?" for help.
2985 """
2986 LOCALE_INVARIANT = 0x7F
2987 windll.kernel32.SetThreadLocale(LOCALE_INVARIANT)
2988 cmd_convert = [
2989 'tracerpt.exe',
2990 '-l', self._logname + '.etl',
2991 '-o', self._logname + '.' + logformat,
2992 '-gmt', # Use UTC
2993 '-y', # No prompt
2994 # Use -of XML to get the header of each items after column 19, e.g. all
2995 # the actual headers of 'User Data'.
2996 ]
2997
2998 if logformat == 'csv':
2999 # tracerpt localizes the 'Type' column, for major brainfuck
3000 # entertainment. I can't imagine any sane reason to do that.
3001 cmd_convert.extend(['-of', 'CSV'])
3002 elif logformat == 'csv_utf16':
3003 # This causes it to use UTF-16, which doubles the log size but ensures
3004 # the log is readable for non-ASCII characters.
3005 cmd_convert.extend(['-of', 'CSV', '-en', 'Unicode'])
3006 elif logformat == 'xml':
3007 cmd_convert.extend(['-of', 'XML'])
3008 else:
3009 raise ValueError('Unexpected log format \'%s\'' % logformat)
3010 logging.debug('Running: %s' % cmd_convert)
3011 # This can takes tens of minutes for large logs.
3012 # Redirects all output to stderr.
3013 subprocess.check_call(
3014 cmd_convert,
3015 stdin=subprocess.PIPE,
3016 stdout=sys.stderr,
3017 stderr=sys.stderr)
3018
3019 @staticmethod
3020 def clean_trace(logname):
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003021 for ext in ('', '.csv', '.etl', '.json', '.xml', '.preprocessed'):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003022 if os.path.isfile(logname + ext):
3023 os.remove(logname + ext)
3024
3025 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003026 def parse_log(cls, logname, blacklist, trace_name):
3027 logging.info('parse_log(%s, ..., %s)', logname, trace_name)
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00003028 assert os.path.isabs(logname)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003029
3030 def blacklist_more(filepath):
3031 # All the NTFS metadata is in the form x:\$EXTEND or stuff like that.
3032 return blacklist(filepath) or re.match(r'[A-Z]\:\\\$EXTEND', filepath)
3033
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003034 # Create a list of (Context, result_dict) tuples. This is necessary because
3035 # the csv file may be larger than the amount of available memory.
3036 contexes = [
3037 (
3038 cls.Context(
3039 blacklist_more, item['pid'], item['trace'], item['thunk_cmd']),
3040 {
3041 'output': item['output'],
3042 'trace': item['trace'],
3043 },
3044 )
3045 for item in read_json(logname)['traces']
3046 if not trace_name or item['trace'] == trace_name
3047 ]
3048
3049 # The log may be too large to fit in memory and it is not efficient to read
3050 # it multiple times, so multiplex the contexes instead, which is slightly
3051 # more awkward.
3052 with open('%s.preprocessed' % logname, 'rb') as f:
3053 lines = csv.reader(
3054 f, delimiter='$', quotechar='*', quoting=csv.QUOTE_MINIMAL)
3055 for encoded in lines:
3056 line = [s.decode('utf-8') for s in encoded]
3057 # Convert the PID in-place from hex.
3058 line[cls.Context.PID] = int(line[cls.Context.PID], 16)
3059 for context in contexes:
3060 if 'exception' in context[1]:
3061 continue
3062 try:
3063 context[0].on_line(line)
3064 except TracingFailure:
3065 context[1]['exception'] = sys.exc_info()
3066
3067 for context in contexes:
3068 if 'exception' in context[1]:
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003069 continue
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003070 context[1]['results'] = context[0].to_results()
3071
3072 return [context[1] for context in contexes]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003073
3074
3075def get_api():
3076 """Returns the correct implementation for the current OS."""
3077 if sys.platform == 'cygwin':
3078 raise NotImplementedError(
3079 'Not implemented for cygwin, start the script from Win32 python')
3080 flavors = {
3081 'win32': LogmanTrace,
3082 'darwin': Dtrace,
3083 'sunos5': Dtrace,
3084 'freebsd7': Dtrace,
3085 'freebsd8': Dtrace,
3086 }
3087 # Defaults to strace.
3088 return flavors.get(sys.platform, Strace)()
3089
3090
3091def extract_directories(root_dir, files, blacklist):
3092 """Detects if all the files in a directory are in |files| and if so, replace
3093 the individual files by a Results.Directory instance.
3094
3095 Takes a list of Results.File instances and returns a shorter list of
3096 Results.File and Results.Directory instances.
3097
3098 Arguments:
3099 - root_dir: Optional base directory that shouldn't be search further.
3100 - files: list of Results.File instances.
3101 - blacklist: regexp of files to ignore, for example r'.+\.pyc'.
3102 """
3103 logging.info(
3104 'extract_directories(%s, %d files, ...)' % (root_dir, len(files)))
3105 assert not (root_dir or '').endswith(os.path.sep), root_dir
3106 assert not root_dir or (get_native_path_case(root_dir) == root_dir)
3107 assert not any(isinstance(f, Results.Directory) for f in files)
3108 # Remove non existent files.
3109 files = [f for f in files if f.existent]
3110 if not files:
3111 return files
3112 # All files must share the same root, which can be None.
3113 assert len(set(f.root for f in files)) == 1, set(f.root for f in files)
3114
3115 # Creates a {directory: {filename: File}} mapping, up to root.
3116 buckets = {}
3117 if root_dir:
3118 buckets[root_dir] = {}
3119 for fileobj in files:
3120 path = fileobj.full_path
3121 directory = os.path.dirname(path)
3122 assert directory
3123 # Do not use os.path.basename() so trailing os.path.sep is kept.
3124 basename = path[len(directory)+1:]
3125 files_in_directory = buckets.setdefault(directory, {})
3126 files_in_directory[basename] = fileobj
3127 # Add all the directories recursively up to root.
3128 while True:
3129 old_d = directory
3130 directory = os.path.dirname(directory)
3131 if directory + os.path.sep == root_dir or directory == old_d:
3132 break
3133 buckets.setdefault(directory, {})
3134
3135 root_prefix = len(root_dir) + 1 if root_dir else 0
3136 for directory in sorted(buckets, reverse=True):
3137 actual = set(f for f in os.listdir(directory) if not blacklist(f))
3138 expected = set(buckets[directory])
3139 if not (actual - expected):
3140 parent = os.path.dirname(directory)
3141 buckets[parent][os.path.basename(directory)] = Results.Directory(
3142 root_dir,
3143 directory[root_prefix:],
3144 False,
3145 sum(f.size for f in buckets[directory].itervalues()),
3146 sum(f.nb_files for f in buckets[directory].itervalues()))
3147 # Remove the whole bucket.
3148 del buckets[directory]
3149
3150 # Reverse the mapping with what remains. The original instances are returned,
3151 # so the cached meta data is kept.
3152 files = sum((x.values() for x in buckets.itervalues()), [])
3153 return sorted(files, key=lambda x: x.path)
3154
3155
3156def trace(logfile, cmd, cwd, api, output):
3157 """Traces an executable. Returns (returncode, output) from api.
3158
3159 Arguments:
3160 - logfile: file to write to.
3161 - cmd: command to run.
3162 - cwd: current directory to start the process in.
3163 - api: a tracing api instance.
3164 - output: if True, returns output, otherwise prints it at the console.
3165 """
3166 cmd = fix_python_path(cmd)
3167 api.clean_trace(logfile)
3168 with api.get_tracer(logfile) as tracer:
3169 return tracer.trace(cmd, cwd, 'default', output)
3170
3171
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003172def CMDclean(args):
3173 """Cleans up traces."""
3174 parser = OptionParserTraceInputs(command='clean')
3175 options, args = parser.parse_args(args)
3176 api = get_api()
3177 api.clean_trace(options.log)
3178 return 0
3179
3180
3181def CMDtrace(args):
3182 """Traces an executable."""
3183 parser = OptionParserTraceInputs(command='trace')
3184 parser.allow_interspersed_args = False
3185 parser.add_option(
3186 '-q', '--quiet', action='store_true',
3187 help='Redirects traced executable output to /dev/null')
3188 options, args = parser.parse_args(args)
3189
3190 if not args:
3191 parser.error('Please provide a command to run')
3192
3193 if not os.path.isabs(args[0]) and os.access(args[0], os.X_OK):
3194 args[0] = os.path.abspath(args[0])
3195
3196 api = get_api()
3197 return trace(options.log, args, os.getcwd(), api, options.quiet)[0]
3198
3199
3200def CMDread(args):
3201 """Reads the logs and prints the result."""
3202 parser = OptionParserTraceInputs(command='read')
3203 parser.add_option(
3204 '-V', '--variable',
3205 nargs=2,
3206 action='append',
3207 dest='variables',
3208 metavar='VAR_NAME directory',
3209 default=[],
3210 help=('Variables to replace relative directories against. Example: '
3211 '"-v \'$HOME\' \'/home/%s\'" will replace all occurence of your '
3212 'home dir with $HOME') % getpass.getuser())
3213 parser.add_option(
3214 '--root-dir',
3215 help='Root directory to base everything off it. Anything outside of this '
3216 'this directory will not be reported')
3217 parser.add_option(
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003218 '--trace-name',
3219 help='Only reads one of the trace. Defaults to reading all traces')
3220 parser.add_option(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003221 '-j', '--json', action='store_true',
3222 help='Outputs raw result data as json')
3223 parser.add_option(
3224 '-b', '--blacklist', action='append', default=[],
3225 help='List of regexp to use as blacklist filter')
3226 options, args = parser.parse_args(args)
3227
3228 if options.root_dir:
3229 options.root_dir = os.path.abspath(options.root_dir)
3230
3231 variables = dict(options.variables)
3232 api = get_api()
3233 def blacklist(f):
3234 return any(re.match(b, f) for b in options.blacklist)
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003235 data = api.parse_log(options.log, blacklist, options.trace_name)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003236 # Process each trace.
3237 output_as_json = []
3238 for item in data:
3239 if 'exception' in item:
maruel@chromium.org424f5692012-10-15 15:42:38 +00003240 # Do not abort the other traces.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003241 print >> sys.stderr, (
maruel@chromium.org424f5692012-10-15 15:42:38 +00003242 'Trace %s: Got an exception: %s' % (
3243 item['trace'], item['exception'][1]))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003244 continue
3245 results = item['results']
3246 if options.root_dir:
3247 results = results.strip_root(options.root_dir)
3248
3249 if options.json:
3250 output_as_json.append(results.flatten())
3251 else:
3252 simplified = extract_directories(
3253 options.root_dir, results.files, blacklist)
3254 simplified = [f.replace_variables(variables) for f in simplified]
3255 if len(data) > 1:
3256 print('Trace: %s' % item['trace'])
3257 print('Total: %d' % len(results.files))
3258 print('Non existent: %d' % len(results.non_existent))
3259 for f in results.non_existent:
3260 print(' %s' % f.path)
3261 print(
3262 'Interesting: %d reduced to %d' % (
3263 len(results.existent), len(simplified)))
3264 for f in simplified:
3265 print(' %s' % f.path)
3266
3267 if options.json:
3268 write_json(sys.stdout, output_as_json, False)
3269 return 0
3270
3271
3272class OptionParserWithLogging(optparse.OptionParser):
3273 """Adds --verbose option."""
3274 def __init__(self, verbose=0, **kwargs):
3275 optparse.OptionParser.__init__(self, **kwargs)
3276 self.add_option(
3277 '-v', '--verbose',
3278 action='count',
3279 default=verbose,
3280 help='Use multiple times to increase verbosity')
3281
3282 def parse_args(self, *args, **kwargs):
3283 options, args = optparse.OptionParser.parse_args(self, *args, **kwargs)
3284 levels = [logging.ERROR, logging.INFO, logging.DEBUG]
3285 logging.basicConfig(
3286 level=levels[min(len(levels)-1, options.verbose)],
3287 format='%(levelname)5s %(module)15s(%(lineno)3d): %(message)s')
3288 return options, args
3289
3290
3291class OptionParserWithNiceDescription(OptionParserWithLogging):
3292 """Generates the description with the command's docstring."""
3293 def __init__(self, **kwargs):
3294 """Sets 'description' and 'usage' if not already specified."""
3295 command = kwargs.pop('command', 'help')
3296 kwargs.setdefault(
3297 'description',
3298 re.sub('[\r\n ]{2,}', ' ', get_command_handler(command).__doc__))
3299 kwargs.setdefault('usage', '%%prog %s [options]' % command)
3300 OptionParserWithLogging.__init__(self, **kwargs)
3301
3302
3303class OptionParserTraceInputs(OptionParserWithNiceDescription):
3304 """Adds automatic --log handling."""
3305 def __init__(self, **kwargs):
3306 OptionParserWithNiceDescription.__init__(self, **kwargs)
3307 self.add_option(
3308 '-l', '--log', help='Log file to generate or read, required')
3309
3310 def parse_args(self, *args, **kwargs):
3311 """Makes sure the paths make sense.
3312
3313 On Windows, / and \ are often mixed together in a path.
3314 """
3315 options, args = OptionParserWithNiceDescription.parse_args(
3316 self, *args, **kwargs)
3317 if not options.log:
3318 self.error('Must supply a log file with -l')
3319 options.log = os.path.abspath(options.log)
3320 return options, args
3321
3322
3323def extract_documentation():
3324 """Returns a dict {command: description} for each of documented command."""
3325 commands = (
3326 fn[3:]
3327 for fn in dir(sys.modules['__main__'])
3328 if fn.startswith('CMD') and get_command_handler(fn[3:]).__doc__)
3329 return dict((fn, get_command_handler(fn).__doc__) for fn in commands)
3330
3331
3332def CMDhelp(args):
3333 """Prints list of commands or help for a specific command."""
3334 doc = extract_documentation()
3335 # Calculates the optimal offset.
3336 offset = max(len(cmd) for cmd in doc)
3337 format_str = ' %-' + str(offset + 2) + 's %s'
3338 # Generate a one-liner documentation of each commands.
3339 commands_description = '\n'.join(
3340 format_str % (cmd, doc[cmd].split('\n')[0]) for cmd in sorted(doc))
3341
3342 parser = OptionParserWithNiceDescription(
3343 usage='%prog <command> [options]',
3344 description='Commands are:\n%s\n' % commands_description)
3345 parser.format_description = lambda _: parser.description
3346
3347 # Strip out any -h or --help argument.
3348 _, args = parser.parse_args([i for i in args if not i in ('-h', '--help')])
3349 if len(args) == 1:
3350 if not get_command_handler(args[0]):
3351 parser.error('Unknown command %s' % args[0])
3352 # The command was "%prog help command", replaces ourself with
3353 # "%prog command --help" so help is correctly printed out.
3354 return main(args + ['--help'])
3355 elif args:
3356 parser.error('Unknown argument "%s"' % ' '.join(args))
3357 parser.print_help()
3358 return 0
3359
3360
3361def get_command_handler(name):
3362 """Returns the command handler or CMDhelp if it doesn't exist."""
3363 return getattr(sys.modules['__main__'], 'CMD%s' % name, None)
3364
3365
3366def main_impl(argv):
3367 command = get_command_handler(argv[0] if argv else 'help')
3368 if not command:
3369 return CMDhelp(argv)
3370 return command(argv[1:])
3371
3372def main(argv):
3373 try:
3374 main_impl(argv)
3375 except TracingFailure, e:
3376 sys.stderr.write('\nError: ')
3377 sys.stderr.write(str(e))
3378 sys.stderr.write('\n')
3379 return 1
3380
3381
3382if __name__ == '__main__':
3383 sys.exit(main(sys.argv[1:]))