1#
2# Wireshark tests
3#
4# Copyright (c) 2018 Peter Wu <peter@lekensteyn.nl>
5#
6# SPDX-License-Identifier: GPL-2.0-or-later
7#
8'''Fixtures that are specific to Wireshark.'''
9
10from contextlib import contextmanager
11import os
12import re
13import subprocess
14import sys
15import tempfile
16import types
17
18import fixtures
19import subprocesstest
20
21
22@fixtures.fixture(scope='session')
23def capture_interface(request, cmd_dumpcap):
24    '''
25    Name of capture interface. Tests will be skipped if dumpcap is not
26    available or no Loopback interface is available.
27    '''
28    disabled = request.config.getoption('--disable-capture', default=False)
29    if disabled:
30        fixtures.skip('Capture tests are disabled via --disable-capture')
31    proc = subprocess.Popen((cmd_dumpcap, '-D'), stdout=subprocess.PIPE,
32                            stderr=subprocess.PIPE, universal_newlines=True)
33    outs, errs = proc.communicate()
34    if proc.returncode != 0:
35        print('"dumpcap -D" exited with %d. stderr:\n%s' %
36              (proc.returncode, errs))
37        fixtures.skip('Test requires capture privileges and an interface.')
38    # Matches: "lo (Loopback)" (Linux), "lo0 (Loopback)" (macOS) or
39    # "\Device\NPF_{...} (Npcap Loopback Adapter)" (Windows)
40    print('"dumpcap -D" output:\n%s' % (outs,))
41    m = re.search(r'^(\d+)\. .*\(.*Loopback.*\)', outs, re.MULTILINE)
42    if not m:
43        fixtures.skip('Test requires a capture interface.')
44    iface = m.group(1)
45    # Interface found, check for capture privileges (needed for Linux).
46    try:
47        subprocess.check_output((cmd_dumpcap, '-L', '-i', iface),
48                                stderr=subprocess.STDOUT,
49                                universal_newlines=True)
50        return iface
51    except subprocess.CalledProcessError as e:
52        print('"dumpcap -L -i %s" exited with %d. Output:\n%s' % (iface,
53                                                                  e.returncode,
54                                                                  e.output))
55        fixtures.skip('Test requires capture privileges.')
56
57
58@fixtures.fixture(scope='session')
59def program_path(request):
60    '''
61    Path to the Wireshark binaries as set by the --program-path option, the
62    WS_BIN_PATH environment variable or (curdir)/run.
63    '''
64    curdir_run = os.path.join(os.curdir, 'run')
65    if sys.platform == 'win32':
66        curdir_run_config = os.path.join(curdir_run, 'RelWithDebInfo')
67        if os.path.exists(curdir_run_config):
68            curdir_run = curdir_run_config
69    paths = (
70        request.config.getoption('--program-path', default=None),
71        os.environ.get('WS_BIN_PATH'),
72        curdir_run,
73    )
74    for path in paths:
75        if type(path) == str and os.path.isdir(path):
76            return path
77    raise AssertionError('Missing directory with Wireshark binaries')
78
79
80@fixtures.fixture(scope='session')
81def program(program_path, request):
82    skip_if_missing = request.config.getoption('--skip-missing-programs',
83                                               default='')
84    skip_if_missing = skip_if_missing.split(',') if skip_if_missing else []
85    dotexe = ''
86    if sys.platform.startswith('win32'):
87        dotexe = '.exe'
88
89    def resolver(name):
90        path = os.path.abspath(os.path.join(program_path, name + dotexe))
91        if not os.access(path, os.X_OK):
92            if skip_if_missing == ['all'] or name in skip_if_missing:
93                fixtures.skip('Program %s is not available' % (name,))
94            raise AssertionError('Program %s is not available' % (name,))
95        return path
96    return resolver
97
98
99@fixtures.fixture(scope='session')
100def cmd_capinfos(program):
101    return program('capinfos')
102
103
104@fixtures.fixture(scope='session')
105def cmd_dumpcap(program):
106    return program('dumpcap')
107
108
109@fixtures.fixture(scope='session')
110def cmd_mergecap(program):
111    return program('mergecap')
112
113
114@fixtures.fixture(scope='session')
115def cmd_rawshark(program):
116    return program('rawshark')
117
118
119@fixtures.fixture(scope='session')
120def cmd_tshark(program):
121    return program('tshark')
122
123
124@fixtures.fixture(scope='session')
125def cmd_text2pcap(program):
126    return program('text2pcap')
127
128
129@fixtures.fixture(scope='session')
130def cmd_editcap(program):
131    return program('editcap')
132
133
134@fixtures.fixture(scope='session')
135def cmd_wireshark(program):
136    return program('wireshark')
137
138
139@fixtures.fixture(scope='session')
140def wireshark_command(cmd_wireshark):
141    # Windows can always display the GUI and macOS can if we're in a login session.
142    # On Linux, headless mode is used, see QT_QPA_PLATFORM in the 'test_env' fixture.
143    if sys.platform == 'darwin' and 'SECURITYSESSIONID' not in os.environ:
144        fixtures.skip('Wireshark GUI tests require loginwindow session')
145    if sys.platform not in ('win32', 'darwin', 'linux'):
146        if 'DISPLAY' not in os.environ:
147            fixtures.skip('Wireshark GUI tests require DISPLAY')
148    return (cmd_wireshark, '-ogui.update.enabled:FALSE')
149
150
151@fixtures.fixture(scope='session')
152def cmd_extcap(program):
153    def extcap_name(name):
154        if sys.platform == 'darwin':
155            return program(os.path.join('Wireshark.app/Contents/MacOS/extcap', name))
156        else:
157            return program(os.path.join('extcap', name))
158    return extcap_name
159
160
161@fixtures.fixture(scope='session')
162def features(cmd_tshark, make_env):
163    '''Returns an object describing available features in tshark.'''
164    try:
165        tshark_v = subprocess.check_output(
166            (cmd_tshark, '--version'),
167            stderr=subprocess.PIPE,
168            universal_newlines=True,
169            env=make_env()
170        )
171        tshark_v = re.sub(r'\s+', ' ', tshark_v)
172    except subprocess.CalledProcessError as ex:
173        print('Failed to detect tshark features: %s' % (ex,))
174        tshark_v = ''
175    gcry_m = re.search(r'with +Gcrypt +([0-9]+\.[0-9]+)', tshark_v)
176    return types.SimpleNamespace(
177        have_x64='Compiled (64-bit)' in tshark_v,
178        have_lua='with Lua' in tshark_v,
179        have_nghttp2='with nghttp2' in tshark_v,
180        have_kerberos='with MIT Kerberos' in tshark_v or 'with Heimdal Kerberos' in tshark_v,
181        have_libgcrypt16=gcry_m and float(gcry_m.group(1)) >= 1.6,
182        have_libgcrypt17=gcry_m and float(gcry_m.group(1)) >= 1.7,
183        have_libgcrypt18=gcry_m and float(gcry_m.group(1)) >= 1.8,
184        have_gnutls='with GnuTLS' in tshark_v,
185        have_pkcs11='and PKCS #11 support' in tshark_v,
186        have_brotli='with brotli' in tshark_v,
187        have_plugins='binary plugins supported' in tshark_v,
188    )
189
190
191@fixtures.fixture(scope='session')
192def dirs():
193    '''Returns fixed directories containing test input.'''
194    this_dir = os.path.dirname(__file__)
195    return types.SimpleNamespace(
196        baseline_dir=os.path.join(this_dir, 'baseline'),
197        capture_dir=os.path.join(this_dir, 'captures'),
198        config_dir=os.path.join(this_dir, 'config'),
199        key_dir=os.path.join(this_dir, 'keys'),
200        lua_dir=os.path.join(this_dir, 'lua'),
201        protobuf_lang_files_dir=os.path.join(this_dir, 'protobuf_lang_files'),
202        tools_dir=os.path.join(this_dir, '..', 'tools'),
203    )
204
205
206@fixtures.fixture(scope='session')
207def capture_file(dirs):
208    '''Returns the path to a capture file.'''
209    def resolver(filename):
210        return os.path.join(dirs.capture_dir, filename)
211    return resolver
212
213
214@fixtures.fixture
215def home_path():
216    '''Per-test home directory, removed when finished.'''
217    with tempfile.TemporaryDirectory(prefix='wireshark-tests-home-') as dirname:
218        yield dirname
219
220
221@fixtures.fixture
222def conf_path(home_path):
223    '''Path to the Wireshark configuration directory.'''
224    if sys.platform.startswith('win32'):
225        conf_path = os.path.join(home_path, 'Wireshark')
226    else:
227        conf_path = os.path.join(home_path, '.config', 'wireshark')
228    os.makedirs(conf_path)
229    return conf_path
230
231
232@fixtures.fixture(scope='session')
233def make_env():
234    """A factory for a modified environment to ensure reproducible tests."""
235    def make_env_real(home=None):
236        env = os.environ.copy()
237        env['TZ'] = 'UTC'
238        home_env = 'APPDATA' if sys.platform.startswith('win32') else 'HOME'
239        if home:
240            env[home_env] = home
241        else:
242            # This directory is supposed not to be written and is used by
243            # "readonly" tests that do not read any other preferences.
244            env[home_env] = "/wireshark-tests-unused"
245        # XDG_CONFIG_HOME takes precedence over HOME, which we don't want.
246        try:
247            del env['XDG_CONFIG_HOME']
248        except KeyError:
249            pass
250        return env
251    return make_env_real
252
253
254@fixtures.fixture
255def base_env(home_path, make_env, request):
256    """A modified environment to ensure reproducible tests. Tests can modify
257    this environment as they see fit."""
258    env = make_env(home=home_path)
259
260    # Remove this if test instances no longer inherit from SubprocessTestCase?
261    if isinstance(request.instance, subprocesstest.SubprocessTestCase):
262        # Inject the test environment as default if it was not overridden.
263        request.instance.injected_test_env = env
264    return env
265
266
267@fixtures.fixture
268def test_env(base_env, conf_path, request, dirs):
269    '''A process environment with a populated configuration directory.'''
270    # Populate our UAT files
271    uat_files = [
272        '80211_keys',
273        'dtlsdecrypttablefile',
274        'esp_sa',
275        'ssl_keys',
276        'c1222_decryption_table',
277        'ikev1_decryption_table',
278        'ikev2_decryption_table',
279    ]
280    # uat.c replaces backslashes...
281    key_dir_path = os.path.join(dirs.key_dir, '').replace('\\', '\\x5c')
282    for uat in uat_files:
283        template_file = os.path.join(dirs.config_dir, uat + '.tmpl')
284        out_file = os.path.join(conf_path, uat)
285        with open(template_file, 'r') as f:
286            template_contents = f.read()
287        cf_contents = template_contents.replace('TEST_KEYS_DIR', key_dir_path)
288        with open(out_file, 'w') as f:
289            f.write(cf_contents)
290
291    env = base_env
292    env['WIRESHARK_RUN_FROM_BUILD_DIRECTORY'] = '1'
293    env['WIRESHARK_QUIT_AFTER_CAPTURE'] = '1'
294
295    # Allow GUI tests to be run without opening windows nor requiring a Xserver.
296    # Set envvar QT_DEBUG_BACKINGSTORE=1 to save the window contents to a file
297    # in the current directory, output0000.png, output0001.png, etc. Note that
298    # this will overwrite existing files.
299    if sys.platform == 'linux':
300        # This option was verified working on Arch Linux with Qt 5.12.0-2 and
301        # Ubuntu 16.04 with libqt5gui5 5.5.1+dfsg-16ubuntu7.5. On macOS and
302        # Windows it unfortunately crashes (Qt 5.12.0).
303        env['QT_QPA_PLATFORM'] = 'minimal'
304
305    # Remove this if test instances no longer inherit from SubprocessTestCase?
306    if isinstance(request.instance, subprocesstest.SubprocessTestCase):
307        # Inject the test environment as default if it was not overridden.
308        request.instance.injected_test_env = env
309    return env
310
311
312@fixtures.fixture
313def test_env_80211_user_tk(base_env, conf_path, request, dirs):
314    '''A process environment with a populated configuration directory.'''
315    # Populate our UAT files
316    uat_files = [
317        '80211_keys',
318    ]
319    # uat.c replaces backslashes...
320    key_dir_path = os.path.join(dirs.key_dir, '').replace('\\', '\\x5c')
321    for uat in uat_files:
322        template_file = os.path.join(dirs.config_dir, uat + '.user_tk_tmpl')
323        out_file = os.path.join(conf_path, uat)
324        with open(template_file, 'r') as f:
325            template_contents = f.read()
326        cf_contents = template_contents.replace('TEST_KEYS_DIR', key_dir_path)
327        with open(out_file, 'w') as f:
328            f.write(cf_contents)
329
330    env = base_env
331    env['WIRESHARK_RUN_FROM_BUILD_DIRECTORY'] = '1'
332    env['WIRESHARK_QUIT_AFTER_CAPTURE'] = '1'
333
334    # Allow GUI tests to be run without opening windows nor requiring a Xserver.
335    # Set envvar QT_DEBUG_BACKINGSTORE=1 to save the window contents to a file
336    # in the current directory, output0000.png, output0001.png, etc. Note that
337    # this will overwrite existing files.
338    if sys.platform == 'linux':
339        # This option was verified working on Arch Linux with Qt 5.12.0-2 and
340        # Ubuntu 16.04 with libqt5gui5 5.5.1+dfsg-16ubuntu7.5. On macOS and
341        # Windows it unfortunately crashes (Qt 5.12.0).
342        env['QT_QPA_PLATFORM'] = 'minimal'
343
344    # Remove this if test instances no longer inherit from SubprocessTestCase?
345    if isinstance(request.instance, subprocesstest.SubprocessTestCase):
346        # Inject the test environment as default if it was not overridden.
347        request.instance.injected_test_env = env
348    return env
349
350@fixtures.fixture
351def unicode_env(home_path, make_env):
352    '''A Wireshark configuration directory with Unicode in its path.'''
353    home_env = 'APPDATA' if sys.platform.startswith('win32') else 'HOME'
354    uni_home = os.path.join(home_path, 'unicode-Ф-€-中-testcases')
355    env = make_env(home=uni_home)
356    if sys.platform == 'win32':
357        pluginsdir = os.path.join(uni_home, 'Wireshark', 'plugins')
358    else:
359        pluginsdir = os.path.join(uni_home, '.local/lib/wireshark/plugins')
360    os.makedirs(pluginsdir)
361    return types.SimpleNamespace(
362        path=lambda *args: os.path.join(uni_home, *args),
363        env=env,
364        pluginsdir=pluginsdir
365    )
366
367
368@fixtures.fixture(scope='session')
369def make_screenshot():
370    '''Creates a screenshot and save it to a file. Intended for CI purposes.'''
371    def make_screenshot_real(filename):
372        try:
373            if sys.platform == 'darwin':
374                subprocess.check_call(['screencapture', filename])
375            else:
376                print("Creating a screenshot on this platform is not supported")
377                return
378            size = os.path.getsize(filename)
379            print("Created screenshot %s (%d bytes)" % (filename, size))
380        except (subprocess.CalledProcessError, OSError) as e:
381            print("Failed to take screenshot:", e)
382    return make_screenshot_real
383
384
385@fixtures.fixture
386def make_screenshot_on_error(request, make_screenshot):
387    '''Writes a screenshot when a process times out.'''
388    @contextmanager
389    def make_screenshot_on_error_real():
390        try:
391            yield
392        except subprocess.TimeoutExpired:
393            filename = request.instance.filename_from_id('screenshot.png')
394            make_screenshot(filename)
395            raise
396    return make_screenshot_on_error_real
397