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