blob: c126d413d0bff31b011ced3c9fbe073d267b50c2 [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.org4c9b4fa2013-05-29 19:22:14 +00001064 self._scripts_to_cleanup = []
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.org4c9b4fa2013-05-29 19:22:14 +00001095 while self._scripts_to_cleanup:
1096 try:
1097 os.remove(self._scripts_to_cleanup.pop())
1098 except OSError as e:
1099 logging.error('Failed to delete a temporary script: %s', e)
maruel@chromium.org808e3f22013-05-16 16:42:54 +00001100 write_json(self._logname, self._gen_logdata(), False)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001101 finally:
1102 self._initialized = False
1103
1104 def post_process_log(self):
1105 """Post-processes the log so it becomes faster to load afterward.
1106
1107 Must not be used manually when using 'with' construct.
1108 """
1109 assert not self._initialized, 'Must stop tracing first.'
1110
maruel@chromium.org808e3f22013-05-16 16:42:54 +00001111 def _gen_logdata(self):
1112 """Returns the data to be saved in the trace file."""
1113 return {
1114 'traces': self._traces,
1115 }
1116
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001117 def __enter__(self):
1118 """Enables 'with' statement."""
1119 return self
1120
1121 def __exit__(self, exc_type, exc_value, traceback):
1122 """Enables 'with' statement."""
1123 self.close()
1124 # If an exception was thrown, do not process logs.
1125 if not exc_type:
1126 self.post_process_log()
1127
1128 def get_tracer(self, logname):
1129 """Returns an ApiBase.Tracer instance.
1130
1131 Initializes the tracing subsystem, which is a requirement for kernel-based
1132 tracers. Only one tracer instance should be live at a time!
1133
1134 logname is the filepath to the json file that will contain the meta-data
1135 about the logs.
1136 """
1137 return self.Tracer(logname)
1138
1139 @staticmethod
1140 def clean_trace(logname):
1141 """Deletes an old log."""
1142 raise NotImplementedError()
1143
1144 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001145 def parse_log(cls, logname, blacklist, trace_name):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001146 """Processes trace logs and returns the files opened and the files that do
1147 not exist.
1148
1149 It does not track directories.
1150
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001151 Arguments:
1152 - logname: must be an absolute path.
1153 - blacklist: must be a lambda.
1154 - trace_name: optional trace to read, defaults to reading all traces.
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00001155
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001156 Most of the time, files that do not exist are temporary test files that
1157 should be put in /tmp instead. See http://crbug.com/116251.
1158
1159 Returns a list of dict with keys:
1160 - results: A Results instance.
1161 - trace: The corresponding tracename parameter provided to
1162 get_tracer().trace().
1163 - output: Output gathered during execution, if get_tracer().trace(...,
1164 output=False) was used.
1165 """
1166 raise NotImplementedError(cls.__class__.__name__)
1167
1168
1169class Strace(ApiBase):
1170 """strace implies linux."""
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001171 @staticmethod
1172 def load_filename(filename):
1173 """Parses a filename in a log."""
1174 assert isinstance(filename, str)
1175 out = ''
1176 i = 0
1177 while i < len(filename):
1178 c = filename[i]
1179 if c == '\\':
1180 out += chr(int(filename[i+1:i+4], 8))
1181 i += 4
1182 else:
1183 out += c
1184 i += 1
1185 # TODO(maruel): That's not necessarily true that the current code page is
1186 # utf-8.
1187 return out.decode('utf-8')
1188
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001189 class Context(ApiBase.Context):
1190 """Processes a strace log line and keeps the list of existent and non
1191 existent files accessed.
1192
1193 Ignores directories.
1194
1195 Uses late-binding to processes the cwd of each process. The problem is that
1196 strace generates one log file per process it traced but doesn't give any
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001197 information about which process was started when and by who. So process the
1198 logs out of order and use late binding with RelativePath to be able to
1199 deduce the initial directory of each process once all the logs are parsed.
1200
1201 TODO(maruel): Use the script even in the non-sudo case, so log parsing can
1202 be done in two phase: first find the root process, then process the child
1203 processes in order. With that, it should be possible to not use RelativePath
1204 anymore. This would significantly simplify the code!
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001205 """
1206 class Process(ApiBase.Context.Process):
1207 """Represents the state of a process.
1208
1209 Contains all the information retrieved from the pid-specific log.
1210 """
1211 # Function names are using ([a-z_0-9]+)
1212 # This is the most common format. function(args) = result
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001213 RE_HEADER = re.compile(r'^([a-z_0-9]+)\((.*?)\)\s+= (.+)$')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001214 # An interrupted function call, only grab the minimal header.
1215 RE_UNFINISHED = re.compile(r'^([^\(]+)(.*) \<unfinished \.\.\.\>$')
1216 # A resumed function call.
1217 RE_RESUMED = re.compile(r'^<\.\.\. ([^ ]+) resumed> (.+)$')
1218 # A process received a signal.
1219 RE_SIGNAL = re.compile(r'^--- SIG[A-Z]+ .+ ---')
1220 # A process didn't handle a signal. Ignore any junk appearing before,
1221 # because the process was forcibly killed so it won't open any new file.
1222 RE_KILLED = re.compile(
1223 r'^.*\+\+\+ killed by ([A-Z]+)( \(core dumped\))? \+\+\+$')
1224 # The process has exited.
1225 RE_PROCESS_EXITED = re.compile(r'^\+\+\+ exited with (\d+) \+\+\+')
1226 # A call was canceled. Ignore any prefix.
1227 RE_UNAVAILABLE = re.compile(r'^.*\)\s*= \? <unavailable>$')
1228 # Happens when strace fails to even get the function name.
1229 UNNAMED_FUNCTION = '????'
1230
1231 # Corner-case in python, a class member function decorator must not be
1232 # @staticmethod.
1233 def parse_args(regexp, expect_zero): # pylint: disable=E0213
1234 """Automatically convert the str 'args' into a list of processed
1235 arguments.
1236
1237 Arguments:
1238 - regexp is used to parse args.
1239 - expect_zero: one of True, False or None.
1240 - True: will check for result.startswith('0') first and will ignore
1241 the trace line completely otherwise. This is important because for
1242 many functions, the regexp will not process if the call failed.
1243 - False: will check for not result.startswith(('?', '-1')) for the
1244 same reason than with True.
1245 - None: ignore result.
1246 """
1247 def meta_hook(function):
1248 assert function.__name__.startswith('handle_')
1249 def hook(self, args, result):
1250 if expect_zero is True and not result.startswith('0'):
1251 return
1252 if expect_zero is False and result.startswith(('?', '-1')):
1253 return
1254 match = re.match(regexp, args)
1255 if not match:
1256 raise TracingFailure(
1257 'Failed to parse %s(%s) = %s' %
1258 (function.__name__[len('handle_'):], args, result),
1259 None, None, None)
1260 return function(self, match.groups(), result)
1261 return hook
1262 return meta_hook
1263
1264 class RelativePath(object):
1265 """A late-bound relative path."""
1266 def __init__(self, parent, value):
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001267 assert_is_renderable(parent)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001268 self.parent = parent
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001269 assert isinstance(value, (None.__class__, str)), repr(value)
1270 self.value = Strace.load_filename(value) if value else value
1271 if self.value:
1272 assert '\\' not in self.value, value
1273 assert '\\' not in self.value, (repr(value), repr(self.value))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001274
1275 def render(self):
1276 """Returns the current directory this instance is representing.
1277
1278 This function is used to return the late-bound value.
1279 """
1280 if self.value and self.value.startswith(u'/'):
1281 # An absolute path.
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001282 # TODO(maruel): This is wrong, we can't assert it is utf-8.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001283 return self.value
1284 parent = self.parent.render() if self.parent else u'<None>'
1285 if self.value:
1286 return os.path.normpath(os.path.join(parent, self.value))
1287 return parent
1288
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001289 def __init__(self, root, pid):
1290 """Keeps enough information to be able to guess the original process
1291 root.
1292
1293 strace doesn't store which process was the initial process. So more
1294 information needs to be kept so the graph can be reconstructed from the
1295 flat map.
1296 """
1297 logging.info('%s(%d)' % (self.__class__.__name__, pid))
1298 super(Strace.Context.Process, self).__init__(root.blacklist, pid, None)
1299 assert isinstance(root, ApiBase.Context)
1300 self._root = weakref.ref(root)
1301 # The dict key is the function name of the pending call, like 'open'
1302 # or 'execve'.
1303 self._pending_calls = {}
1304 self._line_number = 0
1305 # Current directory when the process started.
1306 self.initial_cwd = self.RelativePath(self._root(), None)
1307 self.parentid = None
maruel@chromium.org9f72e0f2012-10-18 14:48:49 +00001308 self._done = False
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001309
1310 def get_cwd(self):
1311 """Returns the best known value of cwd."""
1312 return self.cwd or self.initial_cwd
1313
1314 def render(self):
1315 """Returns the string value of the RelativePath() object.
1316
1317 Used by RelativePath. Returns the initial directory and not the
1318 current one since the current directory 'cwd' validity is time-limited.
1319
1320 The validity is only guaranteed once all the logs are processed.
1321 """
1322 return self.initial_cwd.render()
1323
1324 def on_line(self, line):
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001325 assert isinstance(line, str)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001326 self._line_number += 1
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001327 try:
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001328 if self._done:
1329 raise TracingFailure(
1330 'Found a trace for a terminated process or corrupted log',
1331 None, None, None)
1332
1333 if self.RE_SIGNAL.match(line):
1334 # Ignore signals.
1335 return
1336
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001337 match = self.RE_KILLED.match(line)
1338 if match:
1339 # Converts a '+++ killed by Foo +++' trace into an exit_group().
1340 self.handle_exit_group(match.group(1), None)
1341 return
1342
1343 match = self.RE_PROCESS_EXITED.match(line)
1344 if match:
1345 # Converts a '+++ exited with 1 +++' trace into an exit_group()
1346 self.handle_exit_group(match.group(1), None)
1347 return
1348
1349 match = self.RE_UNFINISHED.match(line)
1350 if match:
1351 if match.group(1) in self._pending_calls:
1352 raise TracingFailure(
1353 'Found two unfinished calls for the same function',
1354 None, None, None,
1355 self._pending_calls)
1356 self._pending_calls[match.group(1)] = (
1357 match.group(1) + match.group(2))
1358 return
1359
1360 match = self.RE_UNAVAILABLE.match(line)
1361 if match:
1362 # This usually means a process was killed and a pending call was
1363 # canceled.
1364 # TODO(maruel): Look up the last exit_group() trace just above and
1365 # make sure any self._pending_calls[anything] is properly flushed.
1366 return
1367
1368 match = self.RE_RESUMED.match(line)
1369 if match:
1370 if match.group(1) not in self._pending_calls:
1371 raise TracingFailure(
1372 'Found a resumed call that was not logged as unfinished',
1373 None, None, None,
1374 self._pending_calls)
1375 pending = self._pending_calls.pop(match.group(1))
1376 # Reconstruct the line.
1377 line = pending + match.group(2)
1378
1379 match = self.RE_HEADER.match(line)
1380 if not match:
maruel@chromium.orgbcfc9e12013-03-19 18:47:17 +00001381 # The line is corrupted. It happens occasionally when a process is
1382 # killed forcibly with activity going on. Assume the process died.
1383 # No other line can be processed afterward.
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001384 logging.debug('%d is done: %s', self.pid, line)
maruel@chromium.orgbcfc9e12013-03-19 18:47:17 +00001385 self._done = True
1386 return
1387
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001388 if match.group(1) == self.UNNAMED_FUNCTION:
1389 return
1390
1391 # It's a valid line, handle it.
1392 handler = getattr(self, 'handle_%s' % match.group(1), None)
1393 if not handler:
1394 self._handle_unknown(match.group(1), match.group(2), match.group(3))
1395 return handler(match.group(2), match.group(3))
1396 except TracingFailure, e:
1397 # Hack in the values since the handler could be a static function.
1398 e.pid = self.pid
1399 e.line = line
1400 e.line_number = self._line_number
1401 # Re-raise the modified exception.
1402 raise
1403 except (KeyError, NotImplementedError, ValueError), e:
1404 raise TracingFailure(
1405 'Trace generated a %s exception: %s' % (
1406 e.__class__.__name__, str(e)),
1407 self.pid,
1408 self._line_number,
1409 line,
1410 e)
1411
1412 @parse_args(r'^\"(.+?)\", [FKORWX_|]+$', True)
1413 def handle_access(self, args, _result):
1414 self._handle_file(args[0], True)
1415
1416 @parse_args(r'^\"(.+?)\"$', True)
1417 def handle_chdir(self, args, _result):
1418 """Updates cwd."""
1419 self.cwd = self.RelativePath(self, args[0])
1420 logging.debug('handle_chdir(%d, %s)' % (self.pid, self.cwd))
1421
maruel@chromium.org55550722013-05-28 14:43:14 +00001422 def handle_chown(self, _args, result):
1423 pass
1424
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001425 def handle_clone(self, _args, result):
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001426 self._handling_forking('clone', result)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001427
1428 def handle_close(self, _args, _result):
1429 pass
1430
1431 def handle_chmod(self, _args, _result):
1432 pass
1433
1434 def handle_creat(self, _args, _result):
1435 # Ignore files created, since they didn't need to exist.
1436 pass
1437
1438 @parse_args(r'^\"(.+?)\", \[(.+)\], \[\/\* \d+ vars? \*\/\]$', True)
1439 def handle_execve(self, args, _result):
1440 # Even if in practice execve() doesn't returns when it succeeds, strace
1441 # still prints '0' as the result.
1442 filepath = args[0]
1443 self._handle_file(filepath, False)
1444 self.executable = self.RelativePath(self.get_cwd(), filepath)
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001445 try:
1446 self.command = strace_process_quoted_arguments(args[1])
1447 except ValueError as e:
1448 raise TracingFailure(
1449 'Failed to process command line argument:\n%s' % e.args[0],
1450 None, None, None)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001451
1452 def handle_exit_group(self, _args, _result):
1453 """Removes cwd."""
1454 self.cwd = None
1455
csharp@chromium.org11ae0672012-10-30 14:53:17 +00001456 @parse_args(r'^(\d+|AT_FDCWD), \"(.*?)\", ([A-Z\_\|]+)(|, \d+)$', True)
1457 def handle_faccessat(self, args, _results):
1458 if args[0] == 'AT_FDCWD':
1459 self._handle_file(args[1], True)
1460 else:
1461 raise Exception('Relative faccess not implemented.')
1462
maruel@chromium.org0781f322013-05-28 19:45:49 +00001463 def handle_fallocate(self, _args, result):
1464 pass
1465
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001466 def handle_fork(self, args, result):
1467 self._handle_unknown('fork', args, result)
1468
maruel@chromium.org9f72e0f2012-10-18 14:48:49 +00001469 def handle_futex(self, _args, _result):
1470 pass
1471
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001472 def handle_getcwd(self, _args, _result):
1473 pass
1474
1475 @parse_args(r'^\"(.+?)\", \"(.+?)\"$', True)
1476 def handle_link(self, args, _result):
1477 self._handle_file(args[0], False)
1478 self._handle_file(args[1], False)
1479
1480 @parse_args(r'\"(.+?)\", \{.+?, \.\.\.\}', True)
1481 def handle_lstat(self, args, _result):
1482 self._handle_file(args[0], True)
1483
1484 def handle_mkdir(self, _args, _result):
1485 pass
1486
1487 @parse_args(r'^\"(.*?)\", ([A-Z\_\|]+)(|, \d+)$', False)
1488 def handle_open(self, args, _result):
1489 if 'O_DIRECTORY' in args[1]:
1490 return
1491 self._handle_file(args[0], False)
1492
1493 @parse_args(r'^(\d+|AT_FDCWD), \"(.*?)\", ([A-Z\_\|]+)(|, \d+)$', False)
1494 def handle_openat(self, args, _result):
1495 if 'O_DIRECTORY' in args[2]:
1496 return
csharp@chromium.org11ae0672012-10-30 14:53:17 +00001497 if args[0] == 'AT_FDCWD':
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001498 self._handle_file(args[1], False)
1499 else:
1500 # TODO(maruel): Implement relative open if necessary instead of the
1501 # AT_FDCWD flag, let's hope not since this means tracking all active
1502 # directory handles.
1503 raise Exception('Relative open via openat not implemented.')
1504
1505 @parse_args(r'^\"(.+?)\", \".+?\"(\.\.\.)?, \d+$', False)
1506 def handle_readlink(self, args, _result):
1507 self._handle_file(args[0], False)
1508
1509 @parse_args(r'^\"(.+?)\", \"(.+?)\"$', True)
1510 def handle_rename(self, args, _result):
1511 self._handle_file(args[0], False)
1512 self._handle_file(args[1], False)
1513
1514 def handle_rmdir(self, _args, _result):
1515 pass
1516
1517 def handle_setxattr(self, _args, _result):
1518 pass
1519
1520 @parse_args(r'\"(.+?)\", \{.+?, \.\.\.\}', True)
1521 def handle_stat(self, args, _result):
1522 self._handle_file(args[0], True)
1523
1524 def handle_symlink(self, _args, _result):
1525 pass
1526
csharp@chromium.orgfa672922012-11-02 17:43:31 +00001527 @parse_args(r'^\"(.+?)\", \d+', True)
1528 def handle_truncate(self, args, _result):
1529 self._handle_file(args[0], False)
1530
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001531 def handle_unlink(self, _args, _result):
1532 # In theory, the file had to be created anyway.
1533 pass
1534
csharp@chromium.org11ae0672012-10-30 14:53:17 +00001535 def handle_unlinkat(self, _args, _result):
1536 # In theory, the file had to be created anyway.
1537 pass
1538
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001539 def handle_statfs(self, _args, _result):
1540 pass
1541
csharp@chromium.org11ae0672012-10-30 14:53:17 +00001542 def handle_utimensat(self, _args, _result):
1543 pass
1544
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001545 def handle_vfork(self, _args, result):
1546 self._handling_forking('vfork', result)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001547
1548 @staticmethod
1549 def _handle_unknown(function, args, result):
1550 raise TracingFailure(
1551 'Unexpected/unimplemented trace %s(%s)= %s' %
1552 (function, args, result),
1553 None, None, None)
1554
maruel@chromium.org588e09d2013-05-21 14:38:57 +00001555 def _handling_forking(self, name, result):
1556 """Transfers cwd."""
1557 if result.startswith(('?', '-1')):
1558 # The call failed.
1559 return
1560 # Update the other process right away.
1561 childpid = int(result)
1562 child = self._root().get_or_set_proc(childpid)
1563 if child.parentid is not None or childpid in self.children:
1564 raise TracingFailure(
1565 'Found internal inconsitency in process lifetime detection '
1566 'during a %s() call' % name,
1567 None, None, None)
1568
1569 # Copy the cwd object.
1570 child.initial_cwd = self.get_cwd()
1571 child.parentid = self.pid
1572 # It is necessary because the logs are processed out of order.
1573 self.children.append(child)
1574
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001575 def _handle_file(self, filepath, touch_only):
1576 filepath = self.RelativePath(self.get_cwd(), filepath)
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001577 #assert not touch_only, render(filepath)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001578 self.add_file(filepath, touch_only)
1579
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001580 def __init__(self, blacklist, root_pid, initial_cwd):
1581 """|root_pid| may be None when the root process is not known.
1582
1583 In that case, a search is done after reading all the logs to figure out
1584 the root process.
1585 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001586 super(Strace.Context, self).__init__(blacklist)
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001587 assert_is_renderable(initial_cwd)
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001588 self.root_pid = root_pid
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001589 self.initial_cwd = initial_cwd
1590
1591 def render(self):
1592 """Returns the string value of the initial cwd of the root process.
1593
1594 Used by RelativePath.
1595 """
1596 return self.initial_cwd
1597
1598 def on_line(self, pid, line):
1599 """Transfers control into the Process.on_line() function."""
1600 self.get_or_set_proc(pid).on_line(line.strip())
1601
1602 def to_results(self):
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001603 """If necessary, finds back the root process and verify consistency."""
1604 if not self.root_pid:
1605 # The non-sudo case. The traced process was started by strace itself,
1606 # so the pid of the traced process is not known.
1607 root = [p for p in self._process_lookup.itervalues() if not p.parentid]
1608 if len(root) == 1:
1609 self.root_process = root[0]
1610 # Save it for later.
1611 self.root_pid = self.root_process.pid
1612 else:
1613 # The sudo case. The traced process was started manually so its pid is
1614 # known.
1615 self.root_process = self._process_lookup.get(self.root_pid)
1616 if not self.root_process:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001617 raise TracingFailure(
1618 'Found internal inconsitency in process lifetime detection '
1619 'while finding the root process',
1620 None,
1621 None,
1622 None,
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001623 self.root_pid,
1624 sorted(self._process_lookup))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001625 process = self.root_process.to_results_process()
1626 if sorted(self._process_lookup) != sorted(p.pid for p in process.all):
1627 raise TracingFailure(
1628 'Found internal inconsitency in process lifetime detection '
1629 'while looking for len(tree) == len(list)',
1630 None,
1631 None,
1632 None,
1633 sorted(self._process_lookup),
1634 sorted(p.pid for p in process.all))
1635 return Results(process)
1636
1637 def get_or_set_proc(self, pid):
1638 """Returns the Context.Process instance for this pid or creates a new one.
1639 """
1640 if not pid or not isinstance(pid, int):
1641 raise TracingFailure(
1642 'Unpexpected value for pid: %r' % pid,
1643 pid,
1644 None,
1645 None,
1646 pid)
1647 if pid not in self._process_lookup:
1648 self._process_lookup[pid] = self.Process(self, pid)
1649 return self._process_lookup[pid]
1650
1651 @classmethod
1652 def traces(cls):
1653 """Returns the list of all handled traces to pass this as an argument to
1654 strace.
1655 """
1656 prefix = 'handle_'
1657 return [i[len(prefix):] for i in dir(cls.Process) if i.startswith(prefix)]
1658
1659 class Tracer(ApiBase.Tracer):
1660 MAX_LEN = 256
1661
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00001662 def __init__(self, logname, use_sudo):
1663 super(Strace.Tracer, self).__init__(logname)
1664 self.use_sudo = use_sudo
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001665 if use_sudo:
1666 # TODO(maruel): Use the jump script systematically to make it easy to
1667 # figure out the root process, so RelativePath is not necessary anymore.
maruel@chromium.org4c9b4fa2013-05-29 19:22:14 +00001668 self._child_script = create_exec_thunk()
1669 self._scripts_to_cleanup.append(self._child_script)
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00001670
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001671 def trace(self, cmd, cwd, tracename, output):
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001672 """Runs strace on an executable.
1673
1674 When use_sudo=True, it is a 3-phases process: start the thunk, start
1675 sudo strace with the pid of the thunk and then have the thunk os.execve()
1676 the process to trace.
1677 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001678 logging.info('trace(%s, %s, %s, %s)' % (cmd, cwd, tracename, output))
1679 assert os.path.isabs(cmd[0]), cmd[0]
1680 assert os.path.isabs(cwd), cwd
1681 assert os.path.normpath(cwd) == cwd, cwd
1682 with self._lock:
1683 if not self._initialized:
1684 raise TracingFailure(
1685 'Called Tracer.trace() on an unitialized object',
1686 None, None, None, tracename)
1687 assert tracename not in (i['trace'] for i in self._traces)
1688 stdout = stderr = None
1689 if output:
1690 stdout = subprocess.PIPE
1691 stderr = subprocess.STDOUT
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001692
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001693 # Ensure all file related APIs are hooked.
1694 traces = ','.join(Strace.Context.traces() + ['file'])
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001695 flags = [
maruel@chromium.orga5cfdc32013-05-28 14:46:15 +00001696 # Each child process has its own trace file. It is necessary because
1697 # strace may generate corrupted log file if multiple processes are
1698 # heavily doing syscalls simultaneously.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001699 '-ff',
maruel@chromium.orga5cfdc32013-05-28 14:46:15 +00001700 # Reduce whitespace usage.
1701 '-a1',
1702 # hex encode non-ascii strings.
1703 # TODO(maruel): '-x',
1704 # TODO(maruel): '-ttt',
1705 # Signals are unnecessary noise here. Note the parser can cope with them
1706 # but reduce the unnecessary output.
1707 '-esignal=none',
1708 # Print as much data as wanted.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001709 '-s', '%d' % self.MAX_LEN,
1710 '-e', 'trace=%s' % traces,
1711 '-o', self._logname + '.' + tracename,
1712 ]
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001713
1714 if self.use_sudo:
1715 pipe_r, pipe_w = os.pipe()
1716 # Start the child process paused.
maruel@chromium.org4c9b4fa2013-05-29 19:22:14 +00001717 target_cmd = [sys.executable, self._child_script, str(pipe_r)] + cmd
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001718 logging.debug(' '.join(target_cmd))
1719 child_proc = subprocess.Popen(
1720 target_cmd,
1721 stdin=subprocess.PIPE,
1722 stdout=stdout,
1723 stderr=stderr,
1724 cwd=cwd)
1725
1726 # TODO(maruel): both processes must use the same UID for it to work
1727 # without sudo. Look why -p is failing at the moment without sudo.
1728 trace_cmd = [
1729 'sudo',
1730 'strace',
1731 '-p', str(child_proc.pid),
1732 ] + flags
1733 logging.debug(' '.join(trace_cmd))
1734 strace_proc = subprocess.Popen(
1735 trace_cmd,
1736 cwd=cwd,
1737 stdin=subprocess.PIPE,
1738 stdout=subprocess.PIPE,
1739 stderr=subprocess.PIPE)
1740
1741 line = strace_proc.stderr.readline()
1742 if not re.match(r'^Process \d+ attached \- interrupt to quit$', line):
1743 # TODO(maruel): Raise an exception.
1744 assert False, line
1745
1746 # Now fire the child process.
1747 os.write(pipe_w, 'x')
1748
1749 out = child_proc.communicate()[0]
1750 strace_out = strace_proc.communicate()[0]
1751
1752 # TODO(maruel): if strace_proc.returncode: Add an exception.
1753 saved_out = strace_out if strace_proc.returncode else out
1754 root_pid = child_proc.pid
1755 else:
1756 # Non-sudo case.
1757 trace_cmd = [
1758 'strace',
1759 ] + flags + cmd
1760 logging.debug(' '.join(trace_cmd))
1761 child_proc = subprocess.Popen(
1762 trace_cmd,
1763 cwd=cwd,
1764 stdin=subprocess.PIPE,
1765 stdout=stdout,
1766 stderr=stderr)
1767 out = child_proc.communicate()[0]
1768 # TODO(maruel): Walk the logs and figure out the root process would
1769 # simplify parsing the logs a *lot*.
1770 saved_out = out
1771 # The trace reader will have to figure out.
1772 root_pid = None
1773
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001774 with self._lock:
1775 assert tracename not in (i['trace'] for i in self._traces)
1776 self._traces.append(
1777 {
1778 'cmd': cmd,
1779 'cwd': cwd,
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001780 'output': saved_out,
1781 'pid': root_pid,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001782 'trace': tracename,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001783 })
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001784 return child_proc.returncode, out
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001785
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00001786 def __init__(self, use_sudo=None):
1787 super(Strace, self).__init__()
1788 self.use_sudo = use_sudo
1789
1790 def get_tracer(self, logname):
1791 return self.Tracer(logname, self.use_sudo)
1792
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001793 @staticmethod
1794 def clean_trace(logname):
1795 if os.path.isfile(logname):
1796 os.remove(logname)
1797 # Also delete any pid specific file from previous traces.
1798 for i in glob.iglob(logname + '.*'):
1799 if i.rsplit('.', 1)[1].isdigit():
1800 os.remove(i)
1801
1802 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001803 def parse_log(cls, logname, blacklist, trace_name):
1804 logging.info('parse_log(%s, ..., %s)', logname, trace_name)
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00001805 assert os.path.isabs(logname)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001806 data = read_json(logname)
1807 out = []
1808 for item in data['traces']:
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00001809 if trace_name and item['trace'] != trace_name:
1810 continue
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001811 result = {
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001812 'output': item['output'],
maruel@chromium.org964ddde2012-10-29 18:13:46 +00001813 'trace': item['trace'],
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001814 }
1815 try:
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001816 context = cls.Context(blacklist, item['pid'], item['cwd'])
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001817 for pidfile in glob.iglob('%s.%s.*' % (logname, item['trace'])):
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001818 logging.debug('Reading %s', pidfile)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001819 pid = pidfile.rsplit('.', 1)[1]
1820 if pid.isdigit():
1821 pid = int(pid)
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001822 found_line = False
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001823 for line in open(pidfile, 'rb'):
1824 context.on_line(pid, line)
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001825 found_line = True
1826 if not found_line:
1827 # Ensures that a completely empty trace still creates the
1828 # corresponding Process instance by logging a dummy line.
1829 context.on_line(pid, '')
1830 else:
1831 logging.warning('Found unexpected file %s', pidfile)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001832 result['results'] = context.to_results()
maruel@chromium.org424f5692012-10-15 15:42:38 +00001833 except TracingFailure:
1834 result['exception'] = sys.exc_info()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001835 out.append(result)
1836 return out
1837
1838
1839class Dtrace(ApiBase):
1840 """Uses DTrace framework through dtrace. Requires root access.
1841
1842 Implies Mac OSX.
1843
1844 dtruss can't be used because it has compatibility issues with python.
1845
1846 Also, the pid->cwd handling needs to be done manually since OSX has no way to
1847 get the absolute path of the 'cwd' dtrace variable from the probe.
1848
1849 Also, OSX doesn't populate curpsinfo->pr_psargs properly, see
1850 https://discussions.apple.com/thread/1980539. So resort to handling execve()
1851 manually.
1852
1853 errno is not printed in the log since this implementation currently only cares
1854 about files that were successfully opened.
1855 """
1856 class Context(ApiBase.Context):
1857 # Format: index pid function(args)
1858 RE_HEADER = re.compile(r'^\d+ (\d+) ([a-zA-Z_\-]+)\((.*?)\)$')
1859
1860 # Arguments parsing.
1861 RE_DTRACE_BEGIN = re.compile(r'^\"(.+?)\"$')
1862 RE_CHDIR = re.compile(r'^\"(.+?)\"$')
1863 RE_EXECVE = re.compile(r'^\"(.+?)\", \[(\d+), (.+)\]$')
1864 RE_OPEN = re.compile(r'^\"(.+?)\", (0x[0-9a-z]+), (0x[0-9a-z]+)$')
1865 RE_PROC_START = re.compile(r'^(\d+), \"(.+?)\", (\d+)$')
1866 RE_RENAME = re.compile(r'^\"(.+?)\", \"(.+?)\"$')
1867
1868 O_DIRECTORY = 0x100000
1869
1870 class Process(ApiBase.Context.Process):
1871 def __init__(self, *args):
1872 super(Dtrace.Context.Process, self).__init__(*args)
1873 self.cwd = self.initial_cwd
1874
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001875 def __init__(self, blacklist, thunk_pid, initial_cwd):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001876 logging.info(
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001877 '%s(%d, %s)' % (self.__class__.__name__, thunk_pid, initial_cwd))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001878 super(Dtrace.Context, self).__init__(blacklist)
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001879 assert isinstance(initial_cwd, unicode), initial_cwd
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00001880 # Process ID of the temporary script created by create_subprocess_thunk().
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001881 self._thunk_pid = thunk_pid
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001882 self._initial_cwd = initial_cwd
1883 self._line_number = 0
1884
1885 def on_line(self, line):
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001886 assert isinstance(line, unicode), line
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001887 self._line_number += 1
1888 match = self.RE_HEADER.match(line)
1889 if not match:
1890 raise TracingFailure(
1891 'Found malformed line: %s' % line,
1892 None,
1893 self._line_number,
1894 line)
1895 fn = getattr(
1896 self,
1897 'handle_%s' % match.group(2).replace('-', '_'),
1898 self._handle_ignored)
1899 # It is guaranteed to succeed because of the regexp. Or at least I thought
1900 # it would.
1901 pid = int(match.group(1))
1902 try:
1903 return fn(pid, match.group(3))
1904 except TracingFailure, e:
1905 # Hack in the values since the handler could be a static function.
1906 e.pid = pid
1907 e.line = line
1908 e.line_number = self._line_number
1909 # Re-raise the modified exception.
1910 raise
1911 except (KeyError, NotImplementedError, ValueError), e:
1912 raise TracingFailure(
1913 'Trace generated a %s exception: %s' % (
1914 e.__class__.__name__, str(e)),
1915 pid,
1916 self._line_number,
1917 line,
1918 e)
1919
1920 def to_results(self):
1921 process = self.root_process.to_results_process()
1922 # Internal concistency check.
1923 if sorted(self._process_lookup) != sorted(p.pid for p in process.all):
1924 raise TracingFailure(
1925 'Found internal inconsitency in process lifetime detection '
1926 'while looking for len(tree) == len(list)',
1927 None,
1928 None,
1929 None,
1930 sorted(self._process_lookup),
1931 sorted(p.pid for p in process.all))
1932 return Results(process)
1933
1934 def handle_dtrace_BEGIN(self, _pid, args):
1935 if not self.RE_DTRACE_BEGIN.match(args):
1936 raise TracingFailure(
1937 'Found internal inconsitency in dtrace_BEGIN log line',
1938 None, None, None)
1939
1940 def handle_proc_start(self, pid, args):
1941 """Transfers cwd.
1942
1943 The dtrace script already takes care of only tracing the processes that
1944 are child of the traced processes so there is no need to verify the
1945 process hierarchy.
1946 """
1947 if pid in self._process_lookup:
1948 raise TracingFailure(
1949 'Found internal inconsitency in proc_start: %d started two times' %
1950 pid,
1951 None, None, None)
1952 match = self.RE_PROC_START.match(args)
1953 if not match:
1954 raise TracingFailure(
1955 'Failed to parse arguments: %s' % args,
1956 None, None, None)
1957 ppid = int(match.group(1))
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001958 if ppid == self._thunk_pid and not self.root_process:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001959 proc = self.root_process = self.Process(
1960 self.blacklist, pid, self._initial_cwd)
1961 elif ppid in self._process_lookup:
1962 proc = self.Process(self.blacklist, pid, self._process_lookup[ppid].cwd)
1963 self._process_lookup[ppid].children.append(proc)
1964 else:
1965 # Another process tree, ignore.
1966 return
1967 self._process_lookup[pid] = proc
1968 logging.debug(
1969 'New child: %s -> %d cwd:%s' %
maruel@chromium.org306e0e72012-11-02 18:22:03 +00001970 (ppid, pid, render(proc.initial_cwd)))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001971
1972 def handle_proc_exit(self, pid, _args):
1973 """Removes cwd."""
1974 if pid in self._process_lookup:
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00001975 # self._thunk_pid is not traced itself and other traces run neither.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00001976 self._process_lookup[pid].cwd = None
1977
1978 def handle_execve(self, pid, args):
1979 """Sets the process' executable.
1980
1981 TODO(maruel): Read command line arguments. See
1982 https://discussions.apple.com/thread/1980539 for an example.
1983 https://gist.github.com/1242279
1984
1985 Will have to put the answer at http://stackoverflow.com/questions/7556249.
1986 :)
1987 """
1988 if not pid in self._process_lookup:
1989 # Another process tree, ignore.
1990 return
1991 match = self.RE_EXECVE.match(args)
1992 if not match:
1993 raise TracingFailure(
1994 'Failed to parse arguments: %r' % args,
1995 None, None, None)
1996 proc = self._process_lookup[pid]
1997 proc.executable = match.group(1)
1998 proc.command = self.process_escaped_arguments(match.group(3))
1999 if int(match.group(2)) != len(proc.command):
2000 raise TracingFailure(
2001 'Failed to parse execve() arguments: %s' % args,
2002 None, None, None)
2003
2004 def handle_chdir(self, pid, args):
2005 """Updates cwd."""
2006 if pid not in self._process_lookup:
2007 # Another process tree, ignore.
2008 return
2009 cwd = self.RE_CHDIR.match(args).group(1)
2010 if not cwd.startswith('/'):
2011 cwd2 = os.path.join(self._process_lookup[pid].cwd, cwd)
2012 logging.debug('handle_chdir(%d, %s) -> %s' % (pid, cwd, cwd2))
2013 else:
2014 logging.debug('handle_chdir(%d, %s)' % (pid, cwd))
2015 cwd2 = cwd
2016 self._process_lookup[pid].cwd = cwd2
2017
2018 def handle_open_nocancel(self, pid, args):
2019 """Redirects to handle_open()."""
2020 return self.handle_open(pid, args)
2021
2022 def handle_open(self, pid, args):
2023 if pid not in self._process_lookup:
2024 # Another process tree, ignore.
2025 return
2026 match = self.RE_OPEN.match(args)
2027 if not match:
2028 raise TracingFailure(
2029 'Failed to parse arguments: %s' % args,
2030 None, None, None)
2031 flag = int(match.group(2), 16)
2032 if self.O_DIRECTORY & flag == self.O_DIRECTORY:
2033 # Ignore directories.
2034 return
2035 self._handle_file(pid, match.group(1))
2036
2037 def handle_rename(self, pid, args):
2038 if pid not in self._process_lookup:
2039 # Another process tree, ignore.
2040 return
2041 match = self.RE_RENAME.match(args)
2042 if not match:
2043 raise TracingFailure(
2044 'Failed to parse arguments: %s' % args,
2045 None, None, None)
2046 self._handle_file(pid, match.group(1))
2047 self._handle_file(pid, match.group(2))
2048
2049 def _handle_file(self, pid, filepath):
2050 if not filepath.startswith('/'):
2051 filepath = os.path.join(self._process_lookup[pid].cwd, filepath)
2052 # We can get '..' in the path.
2053 filepath = os.path.normpath(filepath)
2054 # Sadly, still need to filter out directories here;
2055 # saw open_nocancel(".", 0, 0) = 0 lines.
2056 if os.path.isdir(filepath):
2057 return
2058 self._process_lookup[pid].add_file(filepath, False)
2059
2060 def handle_ftruncate(self, pid, args):
2061 """Just used as a signal to kill dtrace, ignoring."""
2062 pass
2063
2064 @staticmethod
2065 def _handle_ignored(pid, args):
2066 """Is called for all the event traces that are not handled."""
2067 raise NotImplementedError('Please implement me')
2068
2069 @staticmethod
2070 def process_escaped_arguments(text):
2071 """Extracts escaped arguments on a string and return the arguments as a
2072 list.
2073
2074 Implemented as an automaton.
2075
2076 Example:
2077 With text = '\\001python2.7\\001-c\\001print(\\"hi\\")\\0', the
2078 function will return ['python2.7', '-c', 'print("hi")]
2079 """
2080 if not text.endswith('\\0'):
2081 raise ValueError('String is not null terminated: %r' % text, text)
2082 text = text[:-2]
2083
2084 def unescape(x):
2085 """Replaces '\\' with '\' and '\?' (where ? is anything) with ?."""
2086 out = []
2087 escaped = False
2088 for i in x:
2089 if i == '\\' and not escaped:
2090 escaped = True
2091 continue
2092 escaped = False
2093 out.append(i)
2094 return ''.join(out)
2095
2096 return [unescape(i) for i in text.split('\\001')]
2097
2098 class Tracer(ApiBase.Tracer):
2099 # pylint: disable=C0301
2100 #
2101 # To understand the following code, you'll want to take a look at:
2102 # http://developers.sun.com/solaris/articles/dtrace_quickref/dtrace_quickref.html
2103 # https://wikis.oracle.com/display/DTrace/Variables
2104 # http://docs.oracle.com/cd/E19205-01/820-4221/
2105 #
2106 # 0. Dump all the valid probes into a text file. It is important, you
2107 # want to redirect into a file and you don't want to constantly 'sudo'.
2108 # $ sudo dtrace -l > probes.txt
2109 #
2110 # 1. Count the number of probes:
2111 # $ wc -l probes.txt
2112 # 81823 # On OSX 10.7, including 1 header line.
2113 #
2114 # 2. List providers, intentionally skipping all the 'syspolicy10925' and the
2115 # likes and skipping the header with NR>1:
2116 # $ awk 'NR>1 { print $2 }' probes.txt | sort | uniq | grep -v '[[:digit:]]'
2117 # dtrace
2118 # fbt
2119 # io
2120 # ip
2121 # lockstat
2122 # mach_trap
2123 # proc
2124 # profile
2125 # sched
2126 # syscall
2127 # tcp
2128 # vminfo
2129 #
2130 # 3. List of valid probes:
2131 # $ grep syscall probes.txt | less
2132 # or use dtrace directly:
2133 # $ sudo dtrace -l -P syscall | less
2134 #
2135 # trackedpid is an associative array where its value can be 0, 1 or 2.
2136 # 0 is for untracked processes and is the default value for items not
2137 # in the associative array.
2138 # 1 is for tracked processes.
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00002139 # 2 is for the script created by create_subprocess_thunk() only. It is not
2140 # tracked itself but all its decendants are.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002141 #
2142 # The script will kill itself only once waiting_to_die == 1 and
2143 # current_processes == 0, so that both getlogin() was called and that
2144 # all traced processes exited.
2145 #
2146 # TODO(maruel): Use cacheable predicates. See
2147 # https://wikis.oracle.com/display/DTrace/Performance+Considerations
2148 D_CODE = """
2149 dtrace:::BEGIN {
2150 waiting_to_die = 0;
2151 current_processes = 0;
2152 logindex = 0;
2153 printf("%d %d %s_%s(\\"%s\\")\\n",
2154 logindex, PID, probeprov, probename, SCRIPT);
2155 logindex++;
2156 }
2157
2158 proc:::start /trackedpid[ppid]/ {
2159 trackedpid[pid] = 1;
2160 current_processes += 1;
2161 printf("%d %d %s_%s(%d, \\"%s\\", %d)\\n",
2162 logindex, pid, probeprov, probename,
2163 ppid,
2164 execname,
2165 current_processes);
2166 logindex++;
2167 }
2168 /* Should use SCRIPT but there is no access to this variable at that
2169 * point. */
2170 proc:::start /ppid == PID && execname == "Python"/ {
2171 trackedpid[pid] = 2;
2172 current_processes += 1;
2173 printf("%d %d %s_%s(%d, \\"%s\\", %d)\\n",
2174 logindex, pid, probeprov, probename,
2175 ppid,
2176 execname,
2177 current_processes);
2178 logindex++;
2179 }
2180 proc:::exit /trackedpid[pid] &&
2181 current_processes == 1 &&
2182 waiting_to_die == 1/ {
2183 trackedpid[pid] = 0;
2184 current_processes -= 1;
2185 printf("%d %d %s_%s(%d)\\n",
2186 logindex, pid, probeprov, probename,
2187 current_processes);
2188 logindex++;
2189 exit(0);
2190 }
2191 proc:::exit /trackedpid[pid]/ {
2192 trackedpid[pid] = 0;
2193 current_processes -= 1;
2194 printf("%d %d %s_%s(%d)\\n",
2195 logindex, pid, probeprov, probename,
2196 current_processes);
2197 logindex++;
2198 }
2199
2200 /* Use an arcane function to detect when we need to die */
2201 syscall::ftruncate:entry /pid == PID && arg0 == FILE_ID/ {
2202 waiting_to_die = 1;
2203 printf("%d %d %s()\\n", logindex, pid, probefunc);
2204 logindex++;
2205 }
2206 syscall::ftruncate:entry /
2207 pid == PID && arg0 == FILE_ID && current_processes == 0/ {
2208 exit(0);
2209 }
2210
2211 syscall::open*:entry /trackedpid[pid] == 1/ {
2212 self->open_arg0 = arg0;
2213 self->open_arg1 = arg1;
2214 self->open_arg2 = arg2;
2215 }
2216 syscall::open*:return /trackedpid[pid] == 1 && errno == 0/ {
2217 this->open_arg0 = copyinstr(self->open_arg0);
2218 printf("%d %d %s(\\"%s\\", 0x%x, 0x%x)\\n",
2219 logindex, pid, probefunc,
2220 this->open_arg0,
2221 self->open_arg1,
2222 self->open_arg2);
2223 logindex++;
2224 this->open_arg0 = 0;
2225 }
2226 syscall::open*:return /trackedpid[pid] == 1/ {
2227 self->open_arg0 = 0;
2228 self->open_arg1 = 0;
2229 self->open_arg2 = 0;
2230 }
2231
2232 syscall::rename:entry /trackedpid[pid] == 1/ {
2233 self->rename_arg0 = arg0;
2234 self->rename_arg1 = arg1;
2235 }
2236 syscall::rename:return /trackedpid[pid] == 1 && errno == 0/ {
2237 this->rename_arg0 = copyinstr(self->rename_arg0);
2238 this->rename_arg1 = copyinstr(self->rename_arg1);
2239 printf("%d %d %s(\\"%s\\", \\"%s\\")\\n",
2240 logindex, pid, probefunc,
2241 this->rename_arg0,
2242 this->rename_arg1);
2243 logindex++;
2244 this->rename_arg0 = 0;
2245 this->rename_arg1 = 0;
2246 }
2247 syscall::rename:return /trackedpid[pid] == 1/ {
2248 self->rename_arg0 = 0;
2249 self->rename_arg1 = 0;
2250 }
2251
2252 /* Track chdir, it's painful because it is only receiving relative path.
2253 */
2254 syscall::chdir:entry /trackedpid[pid] == 1/ {
2255 self->chdir_arg0 = arg0;
2256 }
2257 syscall::chdir:return /trackedpid[pid] == 1 && errno == 0/ {
2258 this->chdir_arg0 = copyinstr(self->chdir_arg0);
2259 printf("%d %d %s(\\"%s\\")\\n",
2260 logindex, pid, probefunc,
2261 this->chdir_arg0);
2262 logindex++;
2263 this->chdir_arg0 = 0;
2264 }
2265 syscall::chdir:return /trackedpid[pid] == 1/ {
2266 self->chdir_arg0 = 0;
2267 }
2268 """
2269
2270 # execve-specific code, tends to throw a lot of exceptions.
2271 D_CODE_EXECVE = """
2272 /* Finally what we care about! */
2273 syscall::exec*:entry /trackedpid[pid]/ {
2274 self->exec_arg0 = copyinstr(arg0);
2275 /* Incrementally probe for a NULL in the argv parameter of execve() to
2276 * figure out argc. */
2277 /* TODO(maruel): Skip the remaining copyin() when a NULL pointer was
2278 * found. */
2279 self->exec_argc = 0;
2280 /* Probe for argc==1 */
2281 this->exec_argv = (user_addr_t*)copyin(
2282 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2283 self->exec_argc = this->exec_argv[self->exec_argc] ?
2284 (self->exec_argc + 1) : self->exec_argc;
2285
2286 /* Probe for argc==2 */
2287 this->exec_argv = (user_addr_t*)copyin(
2288 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2289 self->exec_argc = this->exec_argv[self->exec_argc] ?
2290 (self->exec_argc + 1) : self->exec_argc;
2291
2292 /* Probe for argc==3 */
2293 this->exec_argv = (user_addr_t*)copyin(
2294 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2295 self->exec_argc = this->exec_argv[self->exec_argc] ?
2296 (self->exec_argc + 1) : self->exec_argc;
2297
2298 /* Probe for argc==4 */
2299 this->exec_argv = (user_addr_t*)copyin(
2300 arg1, sizeof(user_addr_t) * (self->exec_argc + 1));
2301 self->exec_argc = this->exec_argv[self->exec_argc] ?
2302 (self->exec_argc + 1) : self->exec_argc;
2303
2304 /* Copy the inputs strings since there is no guarantee they'll be
2305 * present after the call completed. */
2306 self->exec_argv0 = (self->exec_argc > 0) ?
2307 copyinstr(this->exec_argv[0]) : "";
2308 self->exec_argv1 = (self->exec_argc > 1) ?
2309 copyinstr(this->exec_argv[1]) : "";
2310 self->exec_argv2 = (self->exec_argc > 2) ?
2311 copyinstr(this->exec_argv[2]) : "";
2312 self->exec_argv3 = (self->exec_argc > 3) ?
2313 copyinstr(this->exec_argv[3]) : "";
2314 this->exec_argv = 0;
2315 }
2316 syscall::exec*:return /trackedpid[pid] && errno == 0/ {
2317 /* We need to join strings here, as using multiple printf() would
2318 * cause tearing when multiple threads/processes are traced.
2319 * Since it is impossible to escape a string and join it to another one,
2320 * like sprintf("%s%S", previous, more), use hackery.
2321 * Each of the elements are split with a \\1. \\0 cannot be used because
2322 * it is simply ignored. This will conflict with any program putting a
2323 * \\1 in their execve() string but this should be "rare enough" */
2324 this->args = "";
2325 /* Process exec_argv[0] */
2326 this->args = strjoin(
2327 this->args, (self->exec_argc > 0) ? self->exec_argv0 : "");
2328
2329 /* Process exec_argv[1] */
2330 this->args = strjoin(
2331 this->args, (self->exec_argc > 1) ? "\\1" : "");
2332 this->args = strjoin(
2333 this->args, (self->exec_argc > 1) ? self->exec_argv1 : "");
2334
2335 /* Process exec_argv[2] */
2336 this->args = strjoin(
2337 this->args, (self->exec_argc > 2) ? "\\1" : "");
2338 this->args = strjoin(
2339 this->args, (self->exec_argc > 2) ? self->exec_argv2 : "");
2340
2341 /* Process exec_argv[3] */
2342 this->args = strjoin(
2343 this->args, (self->exec_argc > 3) ? "\\1" : "");
2344 this->args = strjoin(
2345 this->args, (self->exec_argc > 3) ? self->exec_argv3 : "");
2346
2347 /* Prints self->exec_argc to permits verifying the internal
2348 * consistency since this code is quite fishy. */
2349 printf("%d %d %s(\\"%s\\", [%d, %S])\\n",
2350 logindex, pid, probefunc,
2351 self->exec_arg0,
2352 self->exec_argc,
2353 this->args);
2354 logindex++;
2355 this->args = 0;
2356 }
2357 syscall::exec*:return /trackedpid[pid]/ {
2358 self->exec_arg0 = 0;
2359 self->exec_argc = 0;
2360 self->exec_argv0 = 0;
2361 self->exec_argv1 = 0;
2362 self->exec_argv2 = 0;
2363 self->exec_argv3 = 0;
2364 }
2365 """
2366
2367 # Code currently not used.
2368 D_EXTRANEOUS = """
2369 /* This is a good learning experience, since it traces a lot of things
2370 * related to the process and child processes.
2371 * Warning: it generates a gigantic log. For example, tracing
2372 * "data/trace_inputs/child1.py --child" generates a 2mb log and takes
2373 * several minutes to execute.
2374 */
2375 /*
2376 mach_trap::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2377 printf("%d %d %s_%s() = %d\\n",
2378 logindex, pid, probeprov, probefunc, errno);
2379 logindex++;
2380 }
2381 proc::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2382 printf("%d %d %s_%s() = %d\\n",
2383 logindex, pid, probeprov, probefunc, errno);
2384 logindex++;
2385 }
2386 sched::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2387 printf("%d %d %s_%s() = %d\\n",
2388 logindex, pid, probeprov, probefunc, errno);
2389 logindex++;
2390 }
2391 syscall::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2392 printf("%d %d %s_%s() = %d\\n",
2393 logindex, pid, probeprov, probefunc, errno);
2394 logindex++;
2395 }
2396 vminfo::: /trackedpid[pid] == 1 || trackedpid[ppid]/ {
2397 printf("%d %d %s_%s() = %d\\n",
2398 logindex, pid, probeprov, probefunc, errno);
2399 logindex++;
2400 }
2401 */
2402 /* TODO(maruel): *stat* functions and friends
2403 syscall::access:return,
2404 syscall::chdir:return,
2405 syscall::chflags:return,
2406 syscall::chown:return,
2407 syscall::chroot:return,
2408 syscall::getattrlist:return,
2409 syscall::getxattr:return,
2410 syscall::lchown:return,
2411 syscall::lstat64:return,
2412 syscall::lstat:return,
2413 syscall::mkdir:return,
2414 syscall::pathconf:return,
2415 syscall::readlink:return,
2416 syscall::removexattr:return,
2417 syscall::setxattr:return,
2418 syscall::stat64:return,
2419 syscall::stat:return,
2420 syscall::truncate:return,
2421 syscall::unlink:return,
2422 syscall::utimes:return,
2423 */
2424 """
2425
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00002426 def __init__(self, logname, use_sudo):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002427 """Starts the log collection with dtrace.
2428
2429 Requires root access or chmod 4555 on dtrace. dtrace is asynchronous so
2430 this needs to wait for dtrace to be "warmed up".
2431 """
2432 super(Dtrace.Tracer, self).__init__(logname)
maruel@chromium.org4c9b4fa2013-05-29 19:22:14 +00002433 # This script is used as a signal to figure out the root process.
2434 self._signal_script = create_subprocess_thunk()
2435 self._scripts_to_cleanup.append(self._signal_script)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002436 # This unique dummy temp file is used to signal the dtrace script that it
2437 # should stop as soon as all the child processes are done. A bit hackish
2438 # but works fine enough.
2439 self._dummy_file_id, self._dummy_file_name = tempfile.mkstemp(
2440 prefix='trace_signal_file')
2441
maruel@chromium.org9b2112a2013-04-05 18:01:02 +00002442 dtrace_path = '/usr/sbin/dtrace'
2443 if not os.path.isfile(dtrace_path):
2444 dtrace_path = 'dtrace'
2445 elif use_sudo is None and (os.stat(dtrace_path).st_mode & stat.S_ISUID):
2446 # No need to sudo. For those following at home, don't do that.
2447 use_sudo = False
2448
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002449 # Note: do not use the -p flag. It's useless if the initial process quits
2450 # too fast, resulting in missing traces from the grand-children. The D
2451 # code manages the dtrace lifetime itself.
2452 trace_cmd = [
maruel@chromium.org9b2112a2013-04-05 18:01:02 +00002453 dtrace_path,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002454 # Use a larger buffer if getting 'out of scratch space' errors.
2455 # Ref: https://wikis.oracle.com/display/DTrace/Options+and+Tunables
2456 '-b', '10m',
2457 '-x', 'dynvarsize=10m',
2458 #'-x', 'dtrace_global_maxsize=1m',
2459 '-x', 'evaltime=exec',
2460 '-o', '/dev/stderr',
2461 '-q',
2462 '-n', self._get_dtrace_code(),
2463 ]
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00002464 if use_sudo is not False:
2465 trace_cmd.insert(0, 'sudo')
2466
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002467 with open(self._logname + '.log', 'wb') as logfile:
2468 self._dtrace = subprocess.Popen(
2469 trace_cmd, stdout=logfile, stderr=subprocess.STDOUT)
2470 logging.debug('Started dtrace pid: %d' % self._dtrace.pid)
2471
2472 # Reads until one line is printed, which signifies dtrace is up and ready.
2473 with open(self._logname + '.log', 'rb') as logfile:
2474 while 'dtrace_BEGIN' not in logfile.readline():
2475 if self._dtrace.poll() is not None:
2476 # Do a busy wait. :/
2477 break
2478 logging.debug('dtrace started')
2479
2480 def _get_dtrace_code(self):
2481 """Setups the D code to implement child process tracking.
2482
2483 Injects the cookie in the script so it knows when to stop.
2484
2485 The script will detect any instance of the script created with
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00002486 create_subprocess_thunk() and will start tracing it.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002487 """
maruel@chromium.orgbe75f282012-11-02 19:34:37 +00002488 out = (
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002489 'inline int PID = %d;\n'
2490 'inline string SCRIPT = "%s";\n'
2491 'inline int FILE_ID = %d;\n'
2492 '\n'
2493 '%s') % (
2494 os.getpid(),
maruel@chromium.org4c9b4fa2013-05-29 19:22:14 +00002495 self._signal_script,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002496 self._dummy_file_id,
maruel@chromium.orgbe75f282012-11-02 19:34:37 +00002497 self.D_CODE)
2498 if os.environ.get('TRACE_INPUTS_DTRACE_ENABLE_EXECVE') == '1':
2499 # Do not enable by default since it tends to spew dtrace: error lines
2500 # because the execve() parameters are not in valid memory at the time of
2501 # logging.
2502 # TODO(maruel): Find a way to make this reliable since it's useful but
2503 # only works in limited/trivial uses cases for now.
2504 out += self.D_CODE_EXECVE
2505 return out
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002506
2507 def trace(self, cmd, cwd, tracename, output):
2508 """Runs dtrace on an executable.
2509
2510 This dtruss is broken when it starts the process itself or when tracing
2511 child processes, this code starts a wrapper process
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00002512 generated with create_subprocess_thunk() which starts the executable to
2513 trace.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002514 """
2515 logging.info('trace(%s, %s, %s, %s)' % (cmd, cwd, tracename, output))
2516 assert os.path.isabs(cmd[0]), cmd[0]
2517 assert os.path.isabs(cwd), cwd
2518 assert os.path.normpath(cwd) == cwd, cwd
2519 with self._lock:
2520 if not self._initialized:
2521 raise TracingFailure(
2522 'Called Tracer.trace() on an unitialized object',
2523 None, None, None, tracename)
2524 assert tracename not in (i['trace'] for i in self._traces)
2525
2526 # Starts the script wrapper to start the child process. This signals the
2527 # dtrace script that this process is to be traced.
2528 stdout = stderr = None
2529 if output:
2530 stdout = subprocess.PIPE
2531 stderr = subprocess.STDOUT
2532 child_cmd = [
2533 sys.executable,
maruel@chromium.org4c9b4fa2013-05-29 19:22:14 +00002534 self._signal_script,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002535 tracename,
2536 ]
2537 # Call a dummy function so that dtrace knows I'm about to launch a process
2538 # that needs to be traced.
2539 # Yummy.
2540 child = subprocess.Popen(
2541 child_cmd + fix_python_path(cmd),
2542 stdin=subprocess.PIPE,
2543 stdout=stdout,
2544 stderr=stderr,
2545 cwd=cwd)
2546 logging.debug('Started child pid: %d' % child.pid)
2547
2548 out = child.communicate()[0]
2549 # This doesn't mean tracing is done, one of the grand-child process may
2550 # still be alive. It will be tracked with the dtrace script.
2551
2552 with self._lock:
2553 assert tracename not in (i['trace'] for i in self._traces)
2554 self._traces.append(
2555 {
2556 'cmd': cmd,
2557 'cwd': cwd,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002558 'pid': child.pid,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002559 'output': out,
maruel@chromium.org964ddde2012-10-29 18:13:46 +00002560 'trace': tracename,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002561 })
2562 return child.returncode, out
2563
2564 def close(self, timeout=None):
2565 """Terminates dtrace."""
2566 logging.debug('close(%s)' % timeout)
2567 try:
2568 try:
2569 super(Dtrace.Tracer, self).close(timeout)
2570 # Signal dtrace that it should stop now.
maruel@chromium.orgf43e68b2012-10-15 20:23:10 +00002571 # ftruncate doesn't exist on Windows.
2572 os.ftruncate(self._dummy_file_id, 0) # pylint: disable=E1101
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002573 if timeout:
2574 start = time.time()
2575 # Use polling. :/
2576 while (self._dtrace.poll() is None and
2577 (time.time() - start) < timeout):
2578 time.sleep(0.1)
2579 self._dtrace.kill()
2580 self._dtrace.wait()
2581 finally:
2582 # Make sure to kill it in any case.
2583 if self._dtrace.poll() is None:
2584 try:
2585 self._dtrace.kill()
2586 self._dtrace.wait()
2587 except OSError:
2588 pass
2589
2590 if self._dtrace.returncode != 0:
2591 # Warn about any dtrace failure but basically ignore it.
2592 print 'dtrace failure: %s' % self._dtrace.returncode
2593 finally:
2594 os.close(self._dummy_file_id)
2595 os.remove(self._dummy_file_name)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002596
2597 def post_process_log(self):
2598 """Sorts the log back in order when each call occured.
2599
2600 dtrace doesn't save the buffer in strict order since it keeps one buffer
2601 per CPU.
2602 """
2603 super(Dtrace.Tracer, self).post_process_log()
2604 logname = self._logname + '.log'
2605 with open(logname, 'rb') as logfile:
2606 lines = [l for l in logfile if l.strip()]
2607 errors = [l for l in lines if l.startswith('dtrace:')]
2608 if errors:
2609 raise TracingFailure(
2610 'Found errors in the trace: %s' % '\n'.join(errors),
2611 None, None, None, logname)
2612 try:
2613 lines = sorted(lines, key=lambda l: int(l.split(' ', 1)[0]))
2614 except ValueError:
2615 raise TracingFailure(
2616 'Found errors in the trace: %s' % '\n'.join(
2617 l for l in lines if l.split(' ', 1)[0].isdigit()),
2618 None, None, None, logname)
2619 with open(logname, 'wb') as logfile:
2620 logfile.write(''.join(lines))
2621
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00002622 def __init__(self, use_sudo=None):
2623 super(Dtrace, self).__init__()
2624 self.use_sudo = use_sudo
2625
2626 def get_tracer(self, logname):
2627 return self.Tracer(logname, self.use_sudo)
2628
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002629 @staticmethod
2630 def clean_trace(logname):
2631 for ext in ('', '.log'):
2632 if os.path.isfile(logname + ext):
2633 os.remove(logname + ext)
2634
2635 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00002636 def parse_log(cls, logname, blacklist, trace_name):
2637 logging.info('parse_log(%s, ..., %s)', logname, trace_name)
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00002638 assert os.path.isabs(logname)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002639
2640 def blacklist_more(filepath):
2641 # All the HFS metadata is in the form /.vol/...
2642 return blacklist(filepath) or re.match(r'^\/\.vol\/.+$', filepath)
2643
2644 data = read_json(logname)
2645 out = []
2646 for item in data['traces']:
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00002647 if trace_name and item['trace'] != trace_name:
2648 continue
maruel@chromium.org964ddde2012-10-29 18:13:46 +00002649 result = {
2650 'output': item['output'],
2651 'trace': item['trace'],
2652 }
2653 try:
2654 context = cls.Context(blacklist_more, item['pid'], item['cwd'])
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002655 # It's fine to assume the file as UTF-8: OSX enforces the file names to
2656 # be valid UTF-8 and we control the log output.
2657 for line in codecs.open(logname + '.log', 'rb', encoding='utf-8'):
maruel@chromium.org964ddde2012-10-29 18:13:46 +00002658 context.on_line(line)
2659 result['results'] = context.to_results()
2660 except TracingFailure:
2661 result['exception'] = sys.exc_info()
2662 out.append(result)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002663 return out
2664
2665
2666class LogmanTrace(ApiBase):
2667 """Uses the native Windows ETW based tracing functionality to trace a child
2668 process.
2669
2670 Caveat: this implementations doesn't track cwd or initial_cwd. It is because
2671 the Windows Kernel doesn't have a concept of 'current working directory' at
2672 all. A Win32 process has a map of current directories, one per drive letter
2673 and it is managed by the user mode kernel32.dll. In kernel, a file is always
2674 opened relative to another file_object or as an absolute path. All the current
2675 working directory logic is done in user mode.
2676 """
2677 class Context(ApiBase.Context):
2678 """Processes a ETW log line and keeps the list of existent and non
2679 existent files accessed.
2680
2681 Ignores directories.
2682 """
2683 # These indexes are for the stripped version in json.
2684 EVENT_NAME = 0
2685 TYPE = 1
2686 PID = 2
2687 TID = 3
2688 PROCESSOR_ID = 4
2689 TIMESTAMP = 5
2690 USER_DATA = 6
2691
2692 class Process(ApiBase.Context.Process):
2693 def __init__(self, *args):
2694 super(LogmanTrace.Context.Process, self).__init__(*args)
2695 # Handle file objects that succeeded.
2696 self.file_objects = {}
2697
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002698 def __init__(self, blacklist, thunk_pid, trace_name, thunk_cmd):
2699 logging.info(
2700 '%s(%d, %s, %s)', self.__class__.__name__, thunk_pid, trace_name,
2701 thunk_cmd)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002702 super(LogmanTrace.Context, self).__init__(blacklist)
2703 self._drive_map = DosDriveMap()
2704 # Threads mapping to the corresponding process id.
2705 self._threads_active = {}
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002706 # Process ID of the tracer, e.g. the temporary script created by
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00002707 # create_subprocess_thunk(). This is tricky because the process id may
2708 # have been reused.
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002709 self._thunk_pid = thunk_pid
2710 self._thunk_cmd = thunk_cmd
2711 self._trace_name = trace_name
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002712 self._line_number = 0
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002713 self._thunk_process = None
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002714
2715 def on_line(self, line):
2716 """Processes a json Event line."""
2717 self._line_number += 1
2718 try:
2719 # By Opcode
2720 handler = getattr(
2721 self,
2722 'handle_%s_%s' % (line[self.EVENT_NAME], line[self.TYPE]),
2723 None)
2724 if not handler:
2725 raise TracingFailure(
2726 'Unexpected event %s_%s' % (
2727 line[self.EVENT_NAME], line[self.TYPE]),
2728 None, None, None)
2729 handler(line)
2730 except TracingFailure, e:
2731 # Hack in the values since the handler could be a static function.
2732 e.pid = line[self.PID]
2733 e.line = line
2734 e.line_number = self._line_number
2735 # Re-raise the modified exception.
2736 raise
2737 except (KeyError, NotImplementedError, ValueError), e:
2738 raise TracingFailure(
2739 'Trace generated a %s exception: %s' % (
2740 e.__class__.__name__, str(e)),
2741 line[self.PID],
2742 self._line_number,
2743 line,
2744 e)
2745
2746 def to_results(self):
2747 if not self.root_process:
2748 raise TracingFailure(
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002749 'Failed to detect the initial process %d' % self._thunk_pid,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002750 None, None, None)
2751 process = self.root_process.to_results_process()
2752 return Results(process)
2753
2754 def _thread_to_process(self, tid):
2755 """Finds the process from the thread id."""
2756 tid = int(tid, 16)
2757 pid = self._threads_active.get(tid)
2758 if not pid or not self._process_lookup.get(pid):
2759 return
2760 return self._process_lookup[pid]
2761
2762 @classmethod
2763 def handle_EventTrace_Header(cls, line):
2764 """Verifies no event was dropped, e.g. no buffer overrun occured."""
2765 BUFFER_SIZE = cls.USER_DATA
2766 #VERSION = cls.USER_DATA + 1
2767 #PROVIDER_VERSION = cls.USER_DATA + 2
2768 #NUMBER_OF_PROCESSORS = cls.USER_DATA + 3
2769 #END_TIME = cls.USER_DATA + 4
2770 #TIMER_RESOLUTION = cls.USER_DATA + 5
2771 #MAX_FILE_SIZE = cls.USER_DATA + 6
2772 #LOG_FILE_MODE = cls.USER_DATA + 7
2773 #BUFFERS_WRITTEN = cls.USER_DATA + 8
2774 #START_BUFFERS = cls.USER_DATA + 9
2775 #POINTER_SIZE = cls.USER_DATA + 10
2776 EVENTS_LOST = cls.USER_DATA + 11
2777 #CPU_SPEED = cls.USER_DATA + 12
2778 #LOGGER_NAME = cls.USER_DATA + 13
2779 #LOG_FILE_NAME = cls.USER_DATA + 14
2780 #BOOT_TIME = cls.USER_DATA + 15
2781 #PERF_FREQ = cls.USER_DATA + 16
2782 #START_TIME = cls.USER_DATA + 17
2783 #RESERVED_FLAGS = cls.USER_DATA + 18
2784 #BUFFERS_LOST = cls.USER_DATA + 19
2785 #SESSION_NAME_STRING = cls.USER_DATA + 20
2786 #LOG_FILE_NAME_STRING = cls.USER_DATA + 21
2787 if line[EVENTS_LOST] != '0':
2788 raise TracingFailure(
2789 ( '%s events were lost during trace, please increase the buffer '
2790 'size from %s') % (line[EVENTS_LOST], line[BUFFER_SIZE]),
2791 None, None, None)
2792
2793 def handle_FileIo_Cleanup(self, line):
2794 """General wisdom: if a file is closed, it's because it was opened.
2795
2796 Note that FileIo_Close is not used since if a file was opened properly but
2797 not closed before the process exits, only Cleanup will be logged.
2798 """
2799 #IRP = self.USER_DATA
2800 TTID = self.USER_DATA + 1 # Thread ID, that's what we want.
2801 FILE_OBJECT = self.USER_DATA + 2
2802 #FILE_KEY = self.USER_DATA + 3
2803 proc = self._thread_to_process(line[TTID])
2804 if not proc:
2805 # Not a process we care about.
2806 return
2807 file_object = line[FILE_OBJECT]
2808 if file_object in proc.file_objects:
2809 proc.add_file(proc.file_objects.pop(file_object), False)
2810
2811 def handle_FileIo_Create(self, line):
2812 """Handles a file open.
2813
2814 All FileIo events are described at
2815 http://msdn.microsoft.com/library/windows/desktop/aa363884.aspx
2816 for some value of 'description'.
2817
2818 " (..) process and thread id values of the IO events (..) are not valid "
2819 http://msdn.microsoft.com/magazine/ee358703.aspx
2820
2821 The FileIo.Create event doesn't return if the CreateFile() call
2822 succeeded, so keep track of the file_object and check that it is
2823 eventually closed with FileIo_Cleanup.
2824 """
2825 #IRP = self.USER_DATA
2826 TTID = self.USER_DATA + 1 # Thread ID, that's what we want.
2827 FILE_OBJECT = self.USER_DATA + 2
2828 #CREATE_OPTIONS = self.USER_DATA + 3
2829 #FILE_ATTRIBUTES = self.USER_DATA + 4
2830 #self.USER_DATA + SHARE_ACCESS = 5
2831 OPEN_PATH = self.USER_DATA + 6
2832
2833 proc = self._thread_to_process(line[TTID])
2834 if not proc:
2835 # Not a process we care about.
2836 return
2837
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002838 raw_path = line[OPEN_PATH]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002839 # Ignore directories and bare drive right away.
2840 if raw_path.endswith(os.path.sep):
2841 return
2842 filepath = self._drive_map.to_win32(raw_path)
2843 # Ignore bare drive right away. Some may still fall through with format
2844 # like '\\?\X:'
2845 if len(filepath) == 2:
2846 return
2847 file_object = line[FILE_OBJECT]
2848 if os.path.isdir(filepath):
2849 # There is no O_DIRECTORY equivalent on Windows. The closed is
2850 # FILE_FLAG_BACKUP_SEMANTICS but it's not exactly right either. So
2851 # simply discard directories are they are found.
2852 return
2853 # Override any stale file object
2854 proc.file_objects[file_object] = filepath
2855
2856 def handle_FileIo_Rename(self, line):
2857 # TODO(maruel): Handle?
2858 pass
2859
2860 def handle_Process_End(self, line):
2861 pid = line[self.PID]
2862 if self._process_lookup.get(pid):
2863 logging.info('Terminated: %d' % pid)
2864 self._process_lookup[pid] = None
2865 else:
2866 logging.debug('Terminated: %d' % pid)
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002867 if self._thunk_process and self._thunk_process.pid == pid:
2868 self._thunk_process = None
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002869
2870 def handle_Process_Start(self, line):
2871 """Handles a new child process started by PID."""
2872 #UNIQUE_PROCESS_KEY = self.USER_DATA
2873 PROCESS_ID = self.USER_DATA + 1
2874 #PARENT_PID = self.USER_DATA + 2
2875 #SESSION_ID = self.USER_DATA + 3
2876 #EXIT_STATUS = self.USER_DATA + 4
2877 #DIRECTORY_TABLE_BASE = self.USER_DATA + 5
2878 #USER_SID = self.USER_DATA + 6
2879 IMAGE_FILE_NAME = self.USER_DATA + 7
2880 COMMAND_LINE = self.USER_DATA + 8
2881
2882 ppid = line[self.PID]
2883 pid = int(line[PROCESS_ID], 16)
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002884 command_line = CommandLineToArgvW(line[COMMAND_LINE])
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002885 logging.debug(
2886 'New process %d->%d (%s) %s' %
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002887 (ppid, pid, line[IMAGE_FILE_NAME], command_line))
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002888
2889 if pid == self._thunk_pid:
2890 # Need to ignore processes we don't know about because the log is
2891 # system-wide. self._thunk_pid shall start only one process.
2892 # This is tricky though because Windows *loves* to reuse process id and
2893 # it happens often that the process ID of the thunk script created by
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00002894 # create_subprocess_thunk() is reused. So just detecting the pid here is
2895 # not sufficient, we must confirm the command line.
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00002896 if command_line[:len(self._thunk_cmd)] != self._thunk_cmd:
2897 logging.info(
2898 'Ignoring duplicate pid %d for %s: %s while searching for %s',
2899 pid, self._trace_name, command_line, self._thunk_cmd)
2900 return
2901
2902 # TODO(maruel): The check is quite weak. Add the thunk path.
2903 if self._thunk_process:
2904 raise TracingFailure(
2905 ( 'Parent process is _thunk_pid(%d) but thunk_process(%d) is '
2906 'already set') % (self._thunk_pid, self._thunk_process.pid),
2907 None, None, None)
2908 proc = self.Process(self.blacklist, pid, None)
2909 self._thunk_process = proc
2910 return
2911 elif ppid == self._thunk_pid and self._thunk_process:
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002912 proc = self.Process(self.blacklist, pid, None)
2913 self.root_process = proc
2914 ppid = None
2915 elif self._process_lookup.get(ppid):
2916 proc = self.Process(self.blacklist, pid, None)
2917 self._process_lookup[ppid].children.append(proc)
2918 else:
2919 # Ignore
2920 return
2921 self._process_lookup[pid] = proc
2922
maruel@chromium.org306e0e72012-11-02 18:22:03 +00002923 proc.command = command_line
2924 proc.executable = line[IMAGE_FILE_NAME]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00002925 # proc.command[0] may be the absolute path of 'executable' but it may be
2926 # anything else too. If it happens that command[0] ends with executable,
2927 # use it, otherwise defaults to the base name.
2928 cmd0 = proc.command[0].lower()
2929 if not cmd0.endswith('.exe'):
2930 # TODO(maruel): That's not strictly true either.
2931 cmd0 += '.exe'
2932 if cmd0.endswith(proc.executable) and os.path.isfile(cmd0):
2933 # Fix the path.
2934 cmd0 = cmd0.replace('/', os.path.sep)
2935 cmd0 = os.path.normpath(cmd0)
2936 proc.executable = get_native_path_case(cmd0)
2937 logging.info(
2938 'New child: %s -> %d %s' % (ppid, pid, proc.executable))
2939
2940 def handle_Thread_End(self, line):
2941 """Has the same parameters as Thread_Start."""
2942 tid = int(line[self.TID], 16)
2943 self._threads_active.pop(tid, None)
2944
2945 def handle_Thread_Start(self, line):
2946 """Handles a new thread created.
2947
2948 Do not use self.PID here since a process' initial thread is created by
2949 the parent process.
2950 """
2951 PROCESS_ID = self.USER_DATA
2952 TTHREAD_ID = self.USER_DATA + 1
2953 #STACK_BASE = self.USER_DATA + 2
2954 #STACK_LIMIT = self.USER_DATA + 3
2955 #USER_STACK_BASE = self.USER_DATA + 4
2956 #USER_STACK_LIMIT = self.USER_DATA + 5
2957 #AFFINITY = self.USER_DATA + 6
2958 #WIN32_START_ADDR = self.USER_DATA + 7
2959 #TEB_BASE = self.USER_DATA + 8
2960 #SUB_PROCESS_TAG = self.USER_DATA + 9
2961 #BASE_PRIORITY = self.USER_DATA + 10
2962 #PAGE_PRIORITY = self.USER_DATA + 11
2963 #IO_PRIORITY = self.USER_DATA + 12
2964 #THREAD_FLAGS = self.USER_DATA + 13
2965 # Do not use self.PID here since a process' initial thread is created by
2966 # the parent process.
2967 pid = int(line[PROCESS_ID], 16)
2968 tid = int(line[TTHREAD_ID], 16)
2969 logging.debug('New thread pid:%d, tid:%d' % (pid, tid))
2970 self._threads_active[tid] = pid
2971
2972 @classmethod
2973 def supported_events(cls):
2974 """Returns all the procesed events."""
2975 out = []
2976 for member in dir(cls):
2977 match = re.match(r'^handle_([A-Za-z]+)_([A-Za-z]+)$', member)
2978 if match:
2979 out.append(match.groups())
2980 return out
2981
2982 class Tracer(ApiBase.Tracer):
2983 # The basic headers.
2984 EXPECTED_HEADER = [
2985 u'Event Name',
2986 u'Type',
2987 u'Event ID',
2988 u'Version',
2989 u'Channel',
2990 u'Level', # 5
2991 u'Opcode',
2992 u'Task',
2993 u'Keyword',
2994 u'PID',
2995 u'TID', # 10
2996 u'Processor Number',
2997 u'Instance ID',
2998 u'Parent Instance ID',
2999 u'Activity ID',
3000 u'Related Activity ID', # 15
3001 u'Clock-Time',
3002 u'Kernel(ms)', # Both have a resolution of ~15ms which makes them
3003 u'User(ms)', # pretty much useless.
3004 u'User Data', # Extra arguments that are event-specific.
3005 ]
3006 # Only the useful headers common to all entries are listed there. Any column
3007 # at 19 or higher is dependent on the specific event.
3008 EVENT_NAME = 0
3009 TYPE = 1
3010 PID = 9
3011 TID = 10
3012 PROCESSOR_ID = 11
3013 TIMESTAMP = 16
3014 NULL_GUID = '{00000000-0000-0000-0000-000000000000}'
3015 USER_DATA = 19
3016
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003017 class CsvReader(object):
3018 """CSV reader that reads files generated by tracerpt.exe.
3019
3020 csv.reader() fails to read them properly, it mangles file names quoted
3021 with "" with a comma in it.
3022 """
3023 # 0. Had a ',' or one of the following ' ' after a comma, next should
3024 # be ' ', '"' or string or ',' for an empty field.
3025 ( HAD_DELIMITER,
3026 # 1. Processing an unquoted field up to ','.
3027 IN_STR,
3028 # 2. Processing a new field starting with '"'.
3029 STARTING_STR_QUOTED,
3030 # 3. Second quote in a row at the start of a field. It could be either
3031 # '""foo""' or '""'. Who the hell thought it was a great idea to use
3032 # the same character for delimiting and escaping?
3033 STARTING_SECOND_QUOTE,
3034 # 4. A quote inside a quoted string where the previous character was
3035 # not a quote, so the string is not empty. Can be either: end of a
3036 # quoted string (a delimiter) or a quote escape. The next char must be
3037 # either '"' or ','.
3038 HAD_QUOTE_IN_QUOTED,
3039 # 5. Second quote inside a quoted string.
3040 HAD_SECOND_QUOTE_IN_A_ROW_IN_QUOTED,
3041 # 6. Processing a field that started with '"'.
3042 IN_STR_QUOTED) = range(7)
3043
3044 def __init__(self, f):
3045 self.f = f
3046
3047 def __iter__(self):
3048 return self
3049
3050 def next(self):
3051 """Splits the line in fields."""
3052 line = self.f.readline()
3053 if not line:
3054 raise StopIteration()
3055 line = line.strip()
3056 fields = []
3057 state = self.HAD_DELIMITER
3058 for i, c in enumerate(line):
3059 if state == self.HAD_DELIMITER:
3060 if c == ',':
3061 # Empty field.
3062 fields.append('')
3063 elif c == ' ':
3064 # Ignore initial whitespaces
3065 pass
3066 elif c == '"':
3067 state = self.STARTING_STR_QUOTED
3068 fields.append('')
3069 else:
3070 # Start of a new field.
3071 state = self.IN_STR
3072 fields.append(c)
3073
3074 elif state == self.IN_STR:
3075 # Do not accept quote inside unquoted field.
3076 assert c != '"', (i, c, line, fields)
3077 if c == ',':
3078 fields[-1] = fields[-1].strip()
3079 state = self.HAD_DELIMITER
3080 else:
3081 fields[-1] = fields[-1] + c
3082
3083 elif state == self.STARTING_STR_QUOTED:
3084 if c == '"':
3085 # Do not store the character yet.
3086 state = self.STARTING_SECOND_QUOTE
3087 else:
3088 state = self.IN_STR_QUOTED
3089 fields[-1] = fields[-1] + c
3090
3091 elif state == self.STARTING_SECOND_QUOTE:
3092 if c == ',':
3093 # It was an empty field. '""' == ''.
3094 state = self.HAD_DELIMITER
3095 else:
3096 fields[-1] = fields[-1] + '"' + c
3097 state = self.IN_STR_QUOTED
3098
3099 elif state == self.HAD_QUOTE_IN_QUOTED:
3100 if c == ',':
3101 # End of the string.
3102 state = self.HAD_DELIMITER
3103 elif c == '"':
3104 state = self.HAD_SECOND_QUOTE_IN_A_ROW_IN_QUOTED
3105 else:
3106 # The previous double-quote was just an unescaped quote.
3107 fields[-1] = fields[-1] + '"' + c
3108 state = self.IN_STR_QUOTED
3109
3110 elif state == self.HAD_SECOND_QUOTE_IN_A_ROW_IN_QUOTED:
3111 if c == ',':
3112 # End of the string.
3113 state = self.HAD_DELIMITER
3114 fields[-1] = fields[-1] + '"'
3115 else:
maruel@chromium.org261d1352013-02-25 19:23:20 +00003116 # That's just how the logger rolls. Revert back to appending the
3117 # char and "guess" it was a quote in a double-quoted string.
3118 state = self.IN_STR_QUOTED
3119 fields[-1] = fields[-1] + '"' + c
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003120
3121 elif state == self.IN_STR_QUOTED:
3122 if c == '"':
3123 # Could be a delimiter or an escape.
3124 state = self.HAD_QUOTE_IN_QUOTED
3125 else:
3126 fields[-1] = fields[-1] + c
3127
3128 if state == self.HAD_SECOND_QUOTE_IN_A_ROW_IN_QUOTED:
3129 fields[-1] = fields[-1] + '"'
3130 else:
3131 assert state in (
3132 # Terminated with a normal field.
3133 self.IN_STR,
3134 # Terminated with an empty field.
3135 self.STARTING_SECOND_QUOTE,
3136 # Terminated with a normal quoted field.
3137 self.HAD_QUOTE_IN_QUOTED), (
3138 line, state, fields)
3139 return fields
3140
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003141 def __init__(self, logname):
3142 """Starts the log collection.
3143
3144 Requires administrative access. logman.exe is synchronous so no need for a
3145 "warmup" call. 'Windows Kernel Trace' is *localized* so use its GUID
3146 instead. The GUID constant name is SystemTraceControlGuid. Lovely.
3147
3148 One can get the list of potentially interesting providers with:
3149 "logman query providers | findstr /i file"
3150 """
3151 super(LogmanTrace.Tracer, self).__init__(logname)
maruel@chromium.org4c9b4fa2013-05-29 19:22:14 +00003152 self._signal_script = create_subprocess_thunk()
3153 self._scripts_to_cleanup.append(self._signal_script)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003154 cmd_start = [
3155 'logman.exe',
3156 'start',
3157 'NT Kernel Logger',
3158 '-p', '{9e814aad-3204-11d2-9a82-006008a86939}',
3159 # splitio,fileiocompletion,syscall,file,cswitch,img
3160 '(process,fileio,thread)',
3161 '-o', self._logname + '.etl',
3162 '-ets', # Send directly to kernel
3163 # Values extracted out of thin air.
3164 # Event Trace Session buffer size in kb.
3165 '-bs', '10240',
3166 # Number of Event Trace Session buffers.
3167 '-nb', '16', '256',
3168 ]
3169 logging.debug('Running: %s' % cmd_start)
3170 try:
3171 subprocess.check_call(
3172 cmd_start,
3173 stdin=subprocess.PIPE,
3174 stdout=subprocess.PIPE,
3175 stderr=subprocess.STDOUT)
3176 except subprocess.CalledProcessError, e:
3177 if e.returncode == -2147024891:
3178 print >> sys.stderr, 'Please restart with an elevated admin prompt'
3179 elif e.returncode == -2144337737:
3180 print >> sys.stderr, (
3181 'A kernel trace was already running, stop it and try again')
3182 raise
3183
3184 def trace(self, cmd, cwd, tracename, output):
3185 logging.info('trace(%s, %s, %s, %s)' % (cmd, cwd, tracename, output))
3186 assert os.path.isabs(cmd[0]), cmd[0]
3187 assert os.path.isabs(cwd), cwd
3188 assert os.path.normpath(cwd) == cwd, cwd
3189 with self._lock:
3190 if not self._initialized:
3191 raise TracingFailure(
3192 'Called Tracer.trace() on an unitialized object',
3193 None, None, None, tracename)
3194 assert tracename not in (i['trace'] for i in self._traces)
3195
3196 # Use "logman -?" for help.
3197
3198 stdout = stderr = None
3199 if output:
3200 stdout = subprocess.PIPE
3201 stderr = subprocess.STDOUT
3202
3203 # Run the child process.
3204 logging.debug('Running: %s' % cmd)
maruel@chromium.org2909b7c2013-05-29 18:52:59 +00003205 # Use the temporary script generated with create_subprocess_thunk() so we
3206 # have a clear pid owner. Since trace_inputs.py can be used as a library
3207 # and could trace multiple processes simultaneously, it makes it more
3208 # complex if the executable to be traced is executed directly here. It
3209 # also solves issues related to logman.exe that needs to be executed to
3210 # control the kernel trace.
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003211 child_cmd = [
3212 sys.executable,
maruel@chromium.org4c9b4fa2013-05-29 19:22:14 +00003213 self._signal_script,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003214 tracename,
3215 ]
3216 child = subprocess.Popen(
3217 child_cmd + fix_python_path(cmd),
3218 cwd=cwd,
3219 stdin=subprocess.PIPE,
3220 stdout=stdout,
3221 stderr=stderr)
3222 logging.debug('Started child pid: %d' % child.pid)
3223 out = child.communicate()[0]
3224 # This doesn't mean all the grand-children are done. Sadly, we don't have
3225 # a good way to determine that.
3226
3227 with self._lock:
3228 assert tracename not in (i['trace'] for i in self._traces)
3229 self._traces.append({
maruel@chromium.orgf43e68b2012-10-15 20:23:10 +00003230 'cmd': cmd,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003231 'cwd': cwd,
maruel@chromium.org964ddde2012-10-29 18:13:46 +00003232 'output': out,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003233 'pid': child.pid,
maruel@chromium.org19e9fcd2012-10-29 18:24:02 +00003234 # Used to figure out the real process when process ids are reused.
3235 'thunk_cmd': child_cmd,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003236 'trace': tracename,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003237 })
3238
3239 return child.returncode, out
3240
3241 def close(self, _timeout=None):
3242 """Stops the kernel log collection and converts the traces to text
3243 representation.
3244 """
3245 with self._lock:
maruel@chromium.org808e3f22013-05-16 16:42:54 +00003246 try:
3247 super(LogmanTrace.Tracer, self).close()
3248 finally:
3249 cmd_stop = [
3250 'logman.exe',
3251 'stop',
3252 'NT Kernel Logger',
3253 '-ets', # Sends the command directly to the kernel.
3254 ]
3255 logging.debug('Running: %s' % cmd_stop)
3256 subprocess.check_call(
3257 cmd_stop,
3258 stdin=subprocess.PIPE,
3259 stdout=subprocess.PIPE,
3260 stderr=subprocess.STDOUT)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003261
3262 def post_process_log(self):
3263 """Converts the .etl file into .csv then into .json."""
3264 super(LogmanTrace.Tracer, self).post_process_log()
3265 logformat = 'csv'
3266 self._convert_log(logformat)
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003267 self._trim_log(logformat)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003268
maruel@chromium.org808e3f22013-05-16 16:42:54 +00003269 def _gen_logdata(self):
3270 return {
3271 'format': 'csv',
3272 'traces': self._traces,
3273 }
3274
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003275 def _trim_log(self, logformat):
3276 """Reduces the amount of data in original log by generating a 'reduced'
3277 log.
3278 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003279 if logformat == 'csv_utf16':
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003280 file_handle = codecs.open(
3281 self._logname + '.' + logformat, 'r', encoding='utf-16')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003282
3283 elif logformat == 'csv':
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003284 assert sys.getfilesystemencoding() == 'mbcs'
3285 file_handle = codecs.open(
3286 self._logname + '.' + logformat, 'r',
3287 encoding=get_current_encoding())
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003288
3289 supported_events = LogmanTrace.Context.supported_events()
3290
3291 def trim(generator):
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003292 """Loads items from the generator and returns the interesting data.
3293
3294 It filters out any uninteresting line and reduce the amount of data in
3295 the trace.
3296 """
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003297 for index, line in enumerate(generator):
3298 if not index:
3299 if line != self.EXPECTED_HEADER:
3300 raise TracingFailure(
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003301 'Found malformed header: %s' % line,
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003302 None, None, None)
3303 continue
3304 # As you can see, the CSV is full of useful non-redundant information:
3305 if (line[2] != '0' or # Event ID
3306 line[3] not in ('2', '3') or # Version
3307 line[4] != '0' or # Channel
3308 line[5] != '0' or # Level
3309 line[7] != '0' or # Task
3310 line[8] != '0x0000000000000000' or # Keyword
3311 line[12] != '' or # Instance ID
3312 line[13] != '' or # Parent Instance ID
3313 line[14] != self.NULL_GUID or # Activity ID
3314 line[15] != ''): # Related Activity ID
3315 raise TracingFailure(
3316 'Found unexpected values in line: %s' % ' '.join(line),
3317 None, None, None)
3318
3319 if (line[self.EVENT_NAME], line[self.TYPE]) not in supported_events:
3320 continue
3321
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003322 yield [
3323 line[self.EVENT_NAME],
3324 line[self.TYPE],
3325 line[self.PID],
3326 line[self.TID],
3327 line[self.PROCESSOR_ID],
3328 line[self.TIMESTAMP],
3329 ] + line[self.USER_DATA:]
3330
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003331 # must not convert the trim() call into a list, since it will use too much
3332 # memory for large trace. use a csv file as a workaround since the json
3333 # parser requires a complete in-memory file.
3334 with open('%s.preprocessed' % self._logname, 'wb') as f:
3335 # $ and * can't be used in file name on windows, reducing the likelihood
3336 # of having to escape a string.
3337 out = csv.writer(
3338 f, delimiter='$', quotechar='*', quoting=csv.QUOTE_MINIMAL)
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003339 for line in trim(self.CsvReader(file_handle)):
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003340 out.writerow([s.encode('utf-8') for s in line])
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003341
3342 def _convert_log(self, logformat):
3343 """Converts the ETL trace to text representation.
3344
3345 Normally, 'csv' is sufficient. If complex scripts are used (like eastern
3346 languages), use 'csv_utf16'. If localization gets in the way, use 'xml'.
3347
3348 Arguments:
3349 - logformat: Text format to be generated, csv, csv_utf16 or xml.
3350
3351 Use "tracerpt -?" for help.
3352 """
3353 LOCALE_INVARIANT = 0x7F
3354 windll.kernel32.SetThreadLocale(LOCALE_INVARIANT)
3355 cmd_convert = [
3356 'tracerpt.exe',
3357 '-l', self._logname + '.etl',
3358 '-o', self._logname + '.' + logformat,
3359 '-gmt', # Use UTC
3360 '-y', # No prompt
3361 # Use -of XML to get the header of each items after column 19, e.g. all
3362 # the actual headers of 'User Data'.
3363 ]
3364
3365 if logformat == 'csv':
3366 # tracerpt localizes the 'Type' column, for major brainfuck
3367 # entertainment. I can't imagine any sane reason to do that.
3368 cmd_convert.extend(['-of', 'CSV'])
3369 elif logformat == 'csv_utf16':
3370 # This causes it to use UTF-16, which doubles the log size but ensures
3371 # the log is readable for non-ASCII characters.
3372 cmd_convert.extend(['-of', 'CSV', '-en', 'Unicode'])
3373 elif logformat == 'xml':
3374 cmd_convert.extend(['-of', 'XML'])
3375 else:
3376 raise ValueError('Unexpected log format \'%s\'' % logformat)
3377 logging.debug('Running: %s' % cmd_convert)
3378 # This can takes tens of minutes for large logs.
3379 # Redirects all output to stderr.
3380 subprocess.check_call(
3381 cmd_convert,
3382 stdin=subprocess.PIPE,
3383 stdout=sys.stderr,
3384 stderr=sys.stderr)
3385
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00003386 def __init__(self, use_sudo=False): # pylint: disable=W0613
3387 super(LogmanTrace, self).__init__()
3388 # Ignore use_sudo. It's irrelevant on Windows but kept to simplify the API.
3389
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003390 @staticmethod
3391 def clean_trace(logname):
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003392 for ext in ('', '.csv', '.etl', '.json', '.xml', '.preprocessed'):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003393 if os.path.isfile(logname + ext):
3394 os.remove(logname + ext)
3395
3396 @classmethod
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003397 def parse_log(cls, logname, blacklist, trace_name):
3398 logging.info('parse_log(%s, ..., %s)', logname, trace_name)
maruel@chromium.org8b056ba2012-10-16 14:04:49 +00003399 assert os.path.isabs(logname)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003400
3401 def blacklist_more(filepath):
3402 # All the NTFS metadata is in the form x:\$EXTEND or stuff like that.
3403 return blacklist(filepath) or re.match(r'[A-Z]\:\\\$EXTEND', filepath)
3404
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003405 # Create a list of (Context, result_dict) tuples. This is necessary because
3406 # the csv file may be larger than the amount of available memory.
3407 contexes = [
3408 (
3409 cls.Context(
3410 blacklist_more, item['pid'], item['trace'], item['thunk_cmd']),
3411 {
3412 'output': item['output'],
3413 'trace': item['trace'],
3414 },
3415 )
3416 for item in read_json(logname)['traces']
3417 if not trace_name or item['trace'] == trace_name
3418 ]
3419
3420 # The log may be too large to fit in memory and it is not efficient to read
3421 # it multiple times, so multiplex the contexes instead, which is slightly
3422 # more awkward.
3423 with open('%s.preprocessed' % logname, 'rb') as f:
3424 lines = csv.reader(
3425 f, delimiter='$', quotechar='*', quoting=csv.QUOTE_MINIMAL)
3426 for encoded in lines:
3427 line = [s.decode('utf-8') for s in encoded]
3428 # Convert the PID in-place from hex.
3429 line[cls.Context.PID] = int(line[cls.Context.PID], 16)
3430 for context in contexes:
3431 if 'exception' in context[1]:
3432 continue
3433 try:
3434 context[0].on_line(line)
3435 except TracingFailure:
3436 context[1]['exception'] = sys.exc_info()
3437
3438 for context in contexes:
3439 if 'exception' in context[1]:
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003440 continue
maruel@chromium.orgeaedeb42012-10-31 14:38:22 +00003441 context[1]['results'] = context[0].to_results()
3442
3443 return [context[1] for context in contexes]
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003444
3445
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00003446def get_api(**kwargs):
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003447 """Returns the correct implementation for the current OS."""
3448 if sys.platform == 'cygwin':
3449 raise NotImplementedError(
3450 'Not implemented for cygwin, start the script from Win32 python')
3451 flavors = {
3452 'win32': LogmanTrace,
3453 'darwin': Dtrace,
3454 'sunos5': Dtrace,
3455 'freebsd7': Dtrace,
3456 'freebsd8': Dtrace,
3457 }
3458 # Defaults to strace.
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00003459 return flavors.get(sys.platform, Strace)(**kwargs)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003460
3461
3462def extract_directories(root_dir, files, blacklist):
3463 """Detects if all the files in a directory are in |files| and if so, replace
3464 the individual files by a Results.Directory instance.
3465
3466 Takes a list of Results.File instances and returns a shorter list of
3467 Results.File and Results.Directory instances.
3468
3469 Arguments:
3470 - root_dir: Optional base directory that shouldn't be search further.
3471 - files: list of Results.File instances.
3472 - blacklist: regexp of files to ignore, for example r'.+\.pyc'.
3473 """
3474 logging.info(
3475 'extract_directories(%s, %d files, ...)' % (root_dir, len(files)))
3476 assert not (root_dir or '').endswith(os.path.sep), root_dir
maruel@chromium.org306e0e72012-11-02 18:22:03 +00003477 # It is important for root_dir to not be a symlinked path, make sure to call
3478 # os.path.realpath() as needed.
3479 assert not root_dir or (
3480 os.path.realpath(get_native_path_case(root_dir)) == root_dir)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003481 assert not any(isinstance(f, Results.Directory) for f in files)
3482 # Remove non existent files.
3483 files = [f for f in files if f.existent]
3484 if not files:
3485 return files
3486 # All files must share the same root, which can be None.
3487 assert len(set(f.root for f in files)) == 1, set(f.root for f in files)
3488
3489 # Creates a {directory: {filename: File}} mapping, up to root.
3490 buckets = {}
3491 if root_dir:
3492 buckets[root_dir] = {}
3493 for fileobj in files:
3494 path = fileobj.full_path
3495 directory = os.path.dirname(path)
3496 assert directory
3497 # Do not use os.path.basename() so trailing os.path.sep is kept.
3498 basename = path[len(directory)+1:]
3499 files_in_directory = buckets.setdefault(directory, {})
3500 files_in_directory[basename] = fileobj
3501 # Add all the directories recursively up to root.
3502 while True:
3503 old_d = directory
3504 directory = os.path.dirname(directory)
3505 if directory + os.path.sep == root_dir or directory == old_d:
3506 break
3507 buckets.setdefault(directory, {})
3508
3509 root_prefix = len(root_dir) + 1 if root_dir else 0
3510 for directory in sorted(buckets, reverse=True):
maruel@chromium.orgb1678e62013-03-19 15:55:17 +00003511 if not os.path.isdir(directory):
3512 logging.debug(
3513 '%s was a directory but doesn\'t exist anymore; ignoring', directory)
3514 continue
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003515 actual = set(f for f in os.listdir(directory) if not blacklist(f))
3516 expected = set(buckets[directory])
3517 if not (actual - expected):
3518 parent = os.path.dirname(directory)
3519 buckets[parent][os.path.basename(directory)] = Results.Directory(
3520 root_dir,
3521 directory[root_prefix:],
3522 False,
3523 sum(f.size for f in buckets[directory].itervalues()),
3524 sum(f.nb_files for f in buckets[directory].itervalues()))
3525 # Remove the whole bucket.
3526 del buckets[directory]
3527
3528 # Reverse the mapping with what remains. The original instances are returned,
3529 # so the cached meta data is kept.
3530 files = sum((x.values() for x in buckets.itervalues()), [])
3531 return sorted(files, key=lambda x: x.path)
3532
3533
3534def trace(logfile, cmd, cwd, api, output):
3535 """Traces an executable. Returns (returncode, output) from api.
3536
3537 Arguments:
3538 - logfile: file to write to.
3539 - cmd: command to run.
3540 - cwd: current directory to start the process in.
3541 - api: a tracing api instance.
3542 - output: if True, returns output, otherwise prints it at the console.
3543 """
3544 cmd = fix_python_path(cmd)
3545 api.clean_trace(logfile)
3546 with api.get_tracer(logfile) as tracer:
3547 return tracer.trace(cmd, cwd, 'default', output)
3548
3549
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003550def CMDclean(args):
3551 """Cleans up traces."""
3552 parser = OptionParserTraceInputs(command='clean')
3553 options, args = parser.parse_args(args)
3554 api = get_api()
3555 api.clean_trace(options.log)
3556 return 0
3557
3558
3559def CMDtrace(args):
3560 """Traces an executable."""
3561 parser = OptionParserTraceInputs(command='trace')
3562 parser.allow_interspersed_args = False
3563 parser.add_option(
3564 '-q', '--quiet', action='store_true',
3565 help='Redirects traced executable output to /dev/null')
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00003566 parser.add_option(
3567 '-s', '--sudo', action='store_true',
3568 help='Use sudo when shelling out the tracer tool (ignored on Windows)')
3569 parser.add_option(
3570 '-n', '--no-sudo', action='store_false',
3571 help='Don\'t use sudo')
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003572 options, args = parser.parse_args(args)
3573
3574 if not args:
3575 parser.error('Please provide a command to run')
3576
3577 if not os.path.isabs(args[0]) and os.access(args[0], os.X_OK):
3578 args[0] = os.path.abspath(args[0])
3579
maruel@chromium.orgf14633b2013-01-25 15:12:52 +00003580 # options.sudo default value is None, which is to do whatever tracer defaults
3581 # do.
3582 api = get_api(use_sudo=options.sudo)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003583 return trace(options.log, args, os.getcwd(), api, options.quiet)[0]
3584
3585
3586def CMDread(args):
3587 """Reads the logs and prints the result."""
3588 parser = OptionParserTraceInputs(command='read')
3589 parser.add_option(
3590 '-V', '--variable',
3591 nargs=2,
3592 action='append',
3593 dest='variables',
3594 metavar='VAR_NAME directory',
3595 default=[],
3596 help=('Variables to replace relative directories against. Example: '
3597 '"-v \'$HOME\' \'/home/%s\'" will replace all occurence of your '
3598 'home dir with $HOME') % getpass.getuser())
3599 parser.add_option(
3600 '--root-dir',
3601 help='Root directory to base everything off it. Anything outside of this '
3602 'this directory will not be reported')
3603 parser.add_option(
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003604 '--trace-name',
3605 help='Only reads one of the trace. Defaults to reading all traces')
3606 parser.add_option(
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003607 '-j', '--json', action='store_true',
3608 help='Outputs raw result data as json')
3609 parser.add_option(
3610 '-b', '--blacklist', action='append', default=[],
3611 help='List of regexp to use as blacklist filter')
3612 options, args = parser.parse_args(args)
3613
3614 if options.root_dir:
maruel@chromium.org7dc17b32013-04-02 22:27:05 +00003615 options.root_dir = get_native_path_case(
3616 unicode(os.path.abspath(options.root_dir)))
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003617
3618 variables = dict(options.variables)
3619 api = get_api()
3620 def blacklist(f):
3621 return any(re.match(b, f) for b in options.blacklist)
maruel@chromium.orgec74ff82012-10-29 18:14:47 +00003622 data = api.parse_log(options.log, blacklist, options.trace_name)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003623 # Process each trace.
3624 output_as_json = []
maruel@chromium.org9e56b592013-05-21 16:56:05 +00003625 try:
3626 for item in data:
3627 if 'exception' in item:
3628 # Do not abort the other traces.
3629 print >> sys.stderr, (
3630 'Trace %s: Got an exception: %s' % (
3631 item['trace'], item['exception'][1]))
3632 continue
3633 results = item['results']
3634 if options.root_dir:
3635 results = results.strip_root(options.root_dir)
3636
3637 if options.json:
3638 output_as_json.append(results.flatten())
3639 else:
3640 simplified = extract_directories(
3641 options.root_dir, results.files, blacklist)
3642 simplified = [f.replace_variables(variables) for f in simplified]
3643 if len(data) > 1:
3644 print('Trace: %s' % item['trace'])
3645 print('Total: %d' % len(results.files))
3646 print('Non existent: %d' % len(results.non_existent))
3647 for f in results.non_existent:
3648 print(' %s' % f.path)
3649 print(
3650 'Interesting: %d reduced to %d' % (
3651 len(results.existent), len(simplified)))
3652 for f in simplified:
3653 print(' %s' % f.path)
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003654
3655 if options.json:
maruel@chromium.org9e56b592013-05-21 16:56:05 +00003656 write_json(sys.stdout, output_as_json, False)
3657 except KeyboardInterrupt:
3658 return 1
3659 except IOError as e:
3660 if e.errno == errno.EPIPE:
3661 # Do not print a stack trace when the output is piped to less and the user
3662 # quits before the whole output was written.
3663 return 1
3664 raise
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003665 return 0
3666
3667
3668class OptionParserWithLogging(optparse.OptionParser):
3669 """Adds --verbose option."""
3670 def __init__(self, verbose=0, **kwargs):
3671 optparse.OptionParser.__init__(self, **kwargs)
3672 self.add_option(
3673 '-v', '--verbose',
3674 action='count',
3675 default=verbose,
3676 help='Use multiple times to increase verbosity')
3677
3678 def parse_args(self, *args, **kwargs):
3679 options, args = optparse.OptionParser.parse_args(self, *args, **kwargs)
3680 levels = [logging.ERROR, logging.INFO, logging.DEBUG]
3681 logging.basicConfig(
3682 level=levels[min(len(levels)-1, options.verbose)],
3683 format='%(levelname)5s %(module)15s(%(lineno)3d): %(message)s')
3684 return options, args
3685
3686
3687class OptionParserWithNiceDescription(OptionParserWithLogging):
3688 """Generates the description with the command's docstring."""
3689 def __init__(self, **kwargs):
3690 """Sets 'description' and 'usage' if not already specified."""
3691 command = kwargs.pop('command', 'help')
3692 kwargs.setdefault(
3693 'description',
3694 re.sub('[\r\n ]{2,}', ' ', get_command_handler(command).__doc__))
3695 kwargs.setdefault('usage', '%%prog %s [options]' % command)
3696 OptionParserWithLogging.__init__(self, **kwargs)
3697
3698
3699class OptionParserTraceInputs(OptionParserWithNiceDescription):
3700 """Adds automatic --log handling."""
3701 def __init__(self, **kwargs):
3702 OptionParserWithNiceDescription.__init__(self, **kwargs)
3703 self.add_option(
3704 '-l', '--log', help='Log file to generate or read, required')
3705
3706 def parse_args(self, *args, **kwargs):
3707 """Makes sure the paths make sense.
3708
3709 On Windows, / and \ are often mixed together in a path.
3710 """
3711 options, args = OptionParserWithNiceDescription.parse_args(
3712 self, *args, **kwargs)
3713 if not options.log:
3714 self.error('Must supply a log file with -l')
3715 options.log = os.path.abspath(options.log)
3716 return options, args
3717
3718
3719def extract_documentation():
3720 """Returns a dict {command: description} for each of documented command."""
3721 commands = (
3722 fn[3:]
3723 for fn in dir(sys.modules['__main__'])
3724 if fn.startswith('CMD') and get_command_handler(fn[3:]).__doc__)
3725 return dict((fn, get_command_handler(fn).__doc__) for fn in commands)
3726
3727
3728def CMDhelp(args):
3729 """Prints list of commands or help for a specific command."""
3730 doc = extract_documentation()
3731 # Calculates the optimal offset.
3732 offset = max(len(cmd) for cmd in doc)
3733 format_str = ' %-' + str(offset + 2) + 's %s'
3734 # Generate a one-liner documentation of each commands.
3735 commands_description = '\n'.join(
3736 format_str % (cmd, doc[cmd].split('\n')[0]) for cmd in sorted(doc))
3737
3738 parser = OptionParserWithNiceDescription(
3739 usage='%prog <command> [options]',
3740 description='Commands are:\n%s\n' % commands_description)
3741 parser.format_description = lambda _: parser.description
3742
3743 # Strip out any -h or --help argument.
3744 _, args = parser.parse_args([i for i in args if not i in ('-h', '--help')])
3745 if len(args) == 1:
3746 if not get_command_handler(args[0]):
3747 parser.error('Unknown command %s' % args[0])
3748 # The command was "%prog help command", replaces ourself with
3749 # "%prog command --help" so help is correctly printed out.
3750 return main(args + ['--help'])
3751 elif args:
3752 parser.error('Unknown argument "%s"' % ' '.join(args))
3753 parser.print_help()
3754 return 0
3755
3756
3757def get_command_handler(name):
3758 """Returns the command handler or CMDhelp if it doesn't exist."""
3759 return getattr(sys.modules['__main__'], 'CMD%s' % name, None)
3760
3761
3762def main_impl(argv):
3763 command = get_command_handler(argv[0] if argv else 'help')
3764 if not command:
3765 return CMDhelp(argv)
3766 return command(argv[1:])
3767
3768def main(argv):
maruel@chromium.orgac36fb72013-05-21 14:50:53 +00003769 disable_buffering()
maruel@chromium.org8fb47fe2012-10-03 20:13:15 +00003770 try:
3771 main_impl(argv)
3772 except TracingFailure, e:
3773 sys.stderr.write('\nError: ')
3774 sys.stderr.write(str(e))
3775 sys.stderr.write('\n')
3776 return 1
3777
3778
3779if __name__ == '__main__':
3780 sys.exit(main(sys.argv[1:]))