Files
LorenEteval_Furious/tests/test_sing_tun_linux_host.py
T
2026-10-11 00:15:39 +08:00

761 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Verify sing-tun's Linux helpers without changing real host networking."""
from Furious.Models.SingTUN import prepareSingTUNSettings, addSingTUNExclusions
from Furious.Service.SingTUNHost import SingTUNHostPlan
from Furious.Frozenlib.SingTUNRouting import (
SingTUNLinuxRouting,
_linuxRouteRanges,
_linuxRules,
_linuxRuleDelete,
_linuxRuleSelectors,
)
from pathlib import Path
from unittest import mock
import importlib
import ipaddress
import json
import os
import shutil
import shlex
import subprocess
import tempfile
import threading
import unittest
def _bash():
"""Use native Bash or Git Bash, never a Windows WSL launcher."""
if os.name != 'nt':
return shutil.which('bash')
git = shutil.which('git')
if git:
candidate = Path(git).resolve().parent.parent / 'bin' / 'bash.exe'
if candidate.exists():
return str(candidate)
return None
class SingTUNLinuxHostTest(unittest.TestCase):
def setUp(self):
self.module = importlib.import_module('Furious.Frozenlib.SingTUNRouting')
routingTable = importlib.import_module('Furious.Frozenlib.SystemRoutingTable')
platform = mock.patch.object(routingTable, 'PLATFORM', 'Linux')
flatpak = mock.patch.object(
routingTable.SystemRuntime, 'flatpakID', return_value=''
)
platform.start()
flatpak.start()
self.addCleanup(platform.stop)
self.addCleanup(flatpak.stop)
self.tun = prepareSingTUNSettings(
{'tun_options': {'Name': 'utun101', 'Inet6Address': ['fd00::1/64']}}
)['tun_options']
self.host = SingTUNLinuxRouting(self.tun, 123456, 2000, threading.Event())
def tearDown(self):
if self.host._process is not None:
self.host._process.poll.return_value = 0
self.host.drain()
def testHelperCreatesUserOwnedTunAndQuotesOnlyHostArguments(self):
scripts = []
process = mock.Mock()
process.wait.return_value = 0
process.poll.return_value = None
def launch(arguments, **kwargs):
self.assertEqual(arguments[:2], ['pkexec', 'bash'])
self.assertEqual(kwargs['stdin'], subprocess.PIPE)
self.assertTrue(
Path(arguments[2]).name.startswith(tempfile.gettempprefix())
)
self.assertEqual(Path(arguments[2]).suffix, '.sh')
scripts.append(Path(arguments[2]).read_text(encoding='utf-8'))
Path(arguments[2]).parent.joinpath('ready').write_text(
'77', encoding='utf-8'
)
return process
link = {'ifname': 'utun101', 'ifindex': 77}
with mock.patch.object(
self.module.os, 'geteuid', return_value=1000, create=True
), mock.patch.object(
self.module.os, 'getuid', return_value=1000, create=True
), mock.patch.object(
self.module.subprocess, 'Popen', side_effect=launch
), mock.patch.object(
self.module, '_command', return_value=json.dumps([link])
):
self.host.prepare()
with self.assertRaisesRegex(RuntimeError, 'already active'):
self.host.execute([])
self.host.applyDNS('1.1.1.1')
self.assertEqual(self.host.index, 77)
self.assertIn('ip tuntap add dev utun101 mode tun user 1000', scripts[0])
self.assertIn('ip link set dev utun101 mtu 1500', scripts[0])
self.assertIn('ip -6 addr add fd00::1/64 dev utun101', scripts[0])
self.assertIn('ip link set dev utun101 up', scripts[0])
self.assertIn('createdIndex=', scripts[0])
self.assertIn('trap ', scripts[0])
self.assertNotIn('ifalias', ''.join(scripts))
self.assertNotIn('furious-sing-tun-', ''.join(scripts))
self.assertEqual(len(scripts), 1)
self.assertIn('/sys/class/net/utun101/ifindex', scripts[0])
self.assertIn('resolvectl dns utun101 1.1.1.1', scripts[0])
self.assertNotIn('socks5', ''.join(scripts))
self.assertIs(self.host._process, process)
self.assertIsNotNone(self.host._directory)
# Forwarding stops first; releasing this same helper needs no pkexec.
with mock.patch.object(
self.module, '_command', return_value='[]'
), mock.patch.object(self.module.subprocess, 'Popen') as launch:
self.host.cleanup()
self.host.cleanup()
launch.assert_not_called()
process.stdin.close.assert_called_once()
self.assertIsNone(self.host._process)
self.assertIsNone(self.host._directory)
bash = _bash()
if bash and Path(bash).exists():
for script in scripts:
checked = subprocess.run(
[bash, '-n', '-c', script], capture_output=True, timeout=5
)
self.assertEqual(
checked.returncode, 0, checked.stderr.decode(errors='replace')
)
def testRootHelperUsesTheSharedLauncherWithoutPkexec(self):
"""An already privileged caller keeps sing-tun's direct Bash behavior."""
process = mock.Mock()
process.poll.return_value = 0
process.wait.return_value = 0
with (
mock.patch.object(self.module.os, 'geteuid', return_value=0, create=True),
mock.patch.object(
self.module.subprocess, 'Popen', return_value=process
) as launch,
mock.patch.object(
self.module.SystemRoutingTable,
'LinuxStartPrivilegedScript',
wraps=self.module.SystemRoutingTable.LinuxStartPrivilegedScript,
) as start,
):
self.host.execute([['test', 'true']])
start.assert_called_once_with(
start.call_args.args[0],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.assertEqual(launch.call_args.args[0], ['bash', start.call_args.args[0]])
process.wait.assert_called_once_with()
self.assertIsNone(self.host._process)
self.assertIsNone(self.host._directory)
def testRecoveryWaitsForAuthorizationWithoutADeadline(self):
process = mock.Mock()
process.poll.return_value = None
entered, authorized, complete = (
threading.Event(),
threading.Event(),
threading.Event(),
)
errors = []
def wait(timeout=None):
self.assertIsNone(timeout)
entered.set()
if not authorized.wait(5):
raise RuntimeError('fixture authorization was not released')
process.poll.return_value = 0
return 0
process.wait.side_effect = wait
def recover():
try:
self.host.execute([['ip', 'link', 'delete', 'dev', 'utun101']])
except Exception as ex:
# Any non-exit exceptions
errors.append(ex)
finally:
complete.set()
with mock.patch.object(
self.module.os, 'geteuid', return_value=1000, create=True
), mock.patch.object(self.module.subprocess, 'Popen', return_value=process):
worker = threading.Thread(target=recover)
worker.start()
try:
self.assertTrue(entered.wait(2))
self.assertFalse(complete.is_set())
self.assertIs(self.host._process, process)
self.assertIsNotNone(self.host._directory)
process.terminate.assert_not_called()
process.kill.assert_not_called()
finally:
authorized.set()
worker.join(5)
self.assertFalse(worker.is_alive())
self.assertEqual(errors, [])
process.wait.assert_called_once_with()
self.assertIsNone(self.host._process)
self.assertIsNone(self.host._directory)
def testDelayedAuthorizationIsUsedForSetupDnsAndDisconnect(self):
"""A slow password entry does not create extra authorization requests."""
process = mock.Mock()
process.wait.return_value = 0
process.poll.return_value = None
clock = [0.0]
script = []
def launch(arguments, **kwargs):
script.append(Path(arguments[-1]))
return process
def advance(_seconds):
clock[0] += 3600
if clock[0] >= 86400:
script[0].parent.joinpath('ready').write_text('77', encoding='utf-8')
with mock.patch.object(
self.module.os, 'geteuid', return_value=1000, create=True
), mock.patch.object(
self.module.os, 'getuid', return_value=1000, create=True
), mock.patch.object(
self.module.subprocess, 'Popen', side_effect=launch
) as launchHelper, mock.patch.object(
self.module.time, 'monotonic', side_effect=lambda: clock[0]
), mock.patch.object(
self.module.time, 'sleep', side_effect=advance
), mock.patch.object(
self.module,
'_command',
return_value=json.dumps([{'ifname': 'utun101', 'ifindex': 77}]),
):
self.host.prepare('9.9.9.9')
self.host.applyDNS('9.9.9.9')
self.host.cancel()
self.host.drain()
process.stdin.close.assert_not_called()
with mock.patch.object(self.module, '_command', return_value='[]'):
self.host.cleanup()
self.assertEqual(clock[0], 86400)
launchHelper.assert_called_once()
process.stdin.close.assert_called_once()
self.assertFalse(self.host.dnsTouched)
def testPendingAuthorizationCancellationRetainsHelperUntilItExits(self):
process = mock.Mock()
process.poll.return_value = None
process.wait.side_effect = subprocess.TimeoutExpired('pkexec', 5)
def launch(arguments, **kwargs):
self.host.cancelled.set()
return process
with mock.patch.object(
self.module.os, 'geteuid', return_value=1000, create=True
), mock.patch.object(self.module.subprocess, 'Popen', side_effect=launch):
with self.assertRaisesRegex(RuntimeError, 'preparation was cancelled'):
self.host.execute([], preparing=True, holding=True)
directory = Path(self.host._directory.name)
self.assertTrue((directory / 'cancelled').exists())
process.stdin.close.assert_called_once()
with self.assertRaisesRegex(RuntimeError, 'still running'):
self.host.drain()
self.assertIs(self.host._process, process)
self.assertTrue(directory.exists())
process.poll.return_value = 126
self.host.drain()
self.assertFalse(directory.exists())
def testLiveForwarderDnsCannotBeReportedAsRestored(self):
"""A refused child reap retains DNS and the authorized helper together."""
plan = SingTUNHostPlan({}, platform='Linux')
plan._linux = self.host
self.host._process = process = mock.Mock()
process.poll.return_value = None
self.host._directory = tempfile.TemporaryDirectory()
self.host._holding = self.host._ready = self.host.dnsTouched = True
with self.assertRaisesRegex(
RuntimeError, 'requires stopping the forwarding child'
):
while not plan.finishDNSCleanup():
self.assertTrue(plan._done.wait(2))
plan._worker.join(2)
self.assertTrue(self.host.dnsTouched)
self.assertIs(self.host._process, process)
process.stdin.close.assert_not_called()
self.host._releaseHelper()
def testScriptRetainsSuccessfulSetupAndRollsBackFailure(self):
bash = _bash()
if not bash or not Path(bash).exists():
self.skipTest('Bash is unavailable for the isolated script fixture')
realPopen = subprocess.Popen
failures = (
('mtu', 'rule', 'dns', 'cancel', 'abandon', 'none', 'eof')
if os.name != 'nt'
else ('mtu', 'rule', 'dns', 'cancel', 'none', 'eof')
)
for failure in failures:
with self.subTest(
failure=failure
), tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
net = root / 'net'
binDirectory = root / 'bin'
net.mkdir()
binDirectory.mkdir()
ip = binDirectory / 'ip'
# This fixture only creates/removes files under its temporary
# root. No real ip, PolicyKit, TUN, route or DNS command is run.
with ip.open('w', encoding='utf-8', newline='\n') as file:
file.write(
'#!/usr/bin/env bash\n'
'set -eu\n'
'device="$FAKE_NET/utun101"\n'
'case "$1 $2 $3" in\n'
'"tuntap add dev")\n'
' mkdir "$device"\n'
' echo 77 > "$device/ifindex"\n'
' if [ "$FAKE_FAILURE" = cancel ]; then touch "$FAKE_CANCEL"; fi\n'
' if [ "$FAKE_FAILURE" = abandon ]; then\n'
' rm "$FAKE_SCRIPT"\n'
' rmdir "${FAKE_CANCEL%/cancelled}"\n'
' fi\n'
' ;;\n'
'"link set dev")\n'
' if [ "$FAKE_FAILURE" = mtu ] && [ "$5" = mtu ]; then exit 1; fi\n'
' ;;\n'
'"-4 rule add"|"-6 rule add")\n'
' count=$(cat "$FAKE_NET/rules" 2>/dev/null || echo 0)\n'
' if [ "$FAKE_FAILURE" = rule ] && [ "$count" = 2 ]; then exit 1; fi\n'
' echo $((count + 1)) > "$FAKE_NET/rules"\n'
' ;;\n'
'"-4 rule del"|"-6 rule del")\n'
' count=$(cat "$FAKE_NET/rules")\n'
' echo $((count - 1)) > "$FAKE_NET/rules"\n'
' ;;\n'
'"-4 addr add"|"-6 addr add"|"-4 route add"|"-6 route add") ;;\n'
'"link delete dev")\n'
' rm "$device/ifindex"\n'
' rmdir "$device"\n'
' ;;\n'
'*) exit 90 ;;\n'
'esac\n'
)
ip.chmod(0o755)
resolver = binDirectory / 'resolvectl'
resolver.write_text(
'#!/usr/bin/env bash\n'
'echo "$1" >> "$FAKE_NET/dns"\n'
'if [ "$FAKE_FAILURE" = dns ] && [ "$1" = domain ]; then exit 1; fi\n',
encoding='utf-8',
)
resolver.chmod(0o755)
def launch(arguments, **kwargs):
self.assertEqual(arguments[:2], ['pkexec', 'bash'])
script = Path(arguments[-1])
text = script.read_text(encoding='utf-8')
text = text.replace('/sys/class/net', net.as_posix())
cancelled = script.parent / 'cancelled'
text = text.replace(str(cancelled), cancelled.as_posix())
text = text.replace(str(script.parent), script.parent.as_posix())
binPath = shlex.quote(binDirectory.as_posix())
if os.name == 'nt':
binPath = '$(cygpath -u ' + binPath + ')'
with script.open('w', encoding='utf-8', newline='\n') as file:
file.write('PATH=' + binPath + ':"$PATH"\n' + text)
environment = os.environ.copy()
environment.update(
FAKE_NET=net.as_posix(),
FAKE_FAILURE=failure,
FAKE_CANCEL=(script.parent / 'cancelled').as_posix(),
FAKE_SCRIPT=script.as_posix(),
)
return realPopen([bash, str(script)], env=environment, **kwargs)
with mock.patch.object(
self.module.os, 'geteuid', return_value=1000, create=True
), mock.patch.object(
self.module.os, 'getuid', return_value=1000, create=True
), mock.patch.object(
self.module.subprocess, 'Popen', side_effect=launch
), mock.patch.object(
self.module,
'_command',
side_effect=lambda args: json.dumps(
[{'ifname': 'utun101', 'ifindex': 77}]
if args == ['ip', '-N', '-j', 'link', 'show']
and (net / 'utun101').exists()
else []
),
):
if failure in ('none', 'eof'):
self.host.prepare()
self.assertEqual(self.host.index, 77)
self.assertTrue((net / 'utun101').exists())
self.assertIsNone(self.host._process.poll())
if failure == 'eof':
# Closing the parent pipe also covers an application
# exit without calling the normal cleanup method.
self.host._releaseHelper()
self.assertEqual(self.host._process.wait(timeout=5), 0)
self.host.cleanup()
else:
with self.assertRaisesRegex(
RuntimeError, 'host operation failed'
):
self.host.prepare()
self.assertFalse((net / 'utun101').exists())
self.assertIsNone(self.host._process)
self.assertIsNone(self.host._directory)
if (net / 'rules').exists():
self.assertEqual((net / 'rules').read_text().strip(), '0')
if (net / 'dns').exists():
self.assertEqual(
(net / 'dns').read_text().splitlines()[-1], 'revert'
)
def testCancellationBeforeAuthorizationCannotAcquireDevice(self):
scripts = []
process = mock.Mock()
process.wait.return_value = 1
process.poll.return_value = 1
def launch(arguments, **kwargs):
directory = Path(arguments[-1]).parent
self.host.cancelled.set()
self.host.cancel()
self.assertTrue((directory / 'cancelled').exists())
scripts.append(Path(arguments[-1]).read_text(encoding='utf-8'))
return process
with mock.patch.object(
self.module.os, 'geteuid', return_value=1000, create=True
), mock.patch.object(self.module.subprocess, 'Popen', side_effect=launch):
with self.assertRaisesRegex(RuntimeError, 'authorization was cancelled'):
self.host.execute(
[['ip', 'link', 'set', 'dev', 'utun101', 'up']], preparing=True
)
self.assertLess(scripts[0].index('cancelled'), scripts[0].index('ip link'))
self.assertIsNone(self.host._process)
def testCancelledWorkDoesNotLaunchAnotherAuthorization(self):
self.host.cancelled.set()
with mock.patch.object(self.module.subprocess, 'Popen') as launch:
with self.assertRaisesRegex(RuntimeError, 'preparation was cancelled'):
self.host.execute(
[['ip', 'link', 'set', 'dev', 'utun101', 'up']], preparing=True
)
launch.assert_not_called()
self.assertIsNone(self.host._directory)
def testRootHelperRunsWithoutPolicyKitAndFailedLaunchReleasesScript(self):
with mock.patch.object(
self.module.os, 'geteuid', return_value=0, create=True
), mock.patch.object(
self.module.subprocess, 'Popen', side_effect=OSError('launch')
) as launch:
with self.assertRaisesRegex(RuntimeError, 'Unable to start'):
self.host.execute([['ip', 'link', 'set', 'dev', 'utun101', 'up']])
self.assertEqual(launch.call_args.args[0][0], 'bash')
self.assertIsNone(self.host._directory)
def testRouteRangesKeepCustomPrefixesAndAllRemoteExclusions(self):
configuration = addSingTUNExclusions(
{'tun_options': self.tun}, ['192.0.2.8', '2001:db8::8']
)
ranges = _linuxRouteRanges(configuration['tun_options'])
for address in ('192.0.2.8', '2001:db8::8'):
ip = ipaddress.ip_address(address)
self.assertFalse(
any(ip.version == prefix.version and ip in prefix for prefix in ranges)
)
self.tun['Inet4RouteAddress'] = ['203.0.113.0/24']
ranges = _linuxRouteRanges(self.tun)
self.assertEqual(
[prefix for prefix in ranges if prefix.version == 4],
[ipaddress.ip_network('203.0.113.0/24')],
)
def testIPv6LoopPreventionPrecedesSourceAndDefaultRules(self):
rules = _linuxRules(self.tun, 123456, 2000)
v6 = [command for command in rules if command[1] == '-6']
goto = next(command for command in v6 if 'utun101' in command)
source = next(command for command in v6 if 'fd00::/64' in command)
default = next(command for command in v6 if command[6:] == ['table', '123456'])
self.assertEqual(goto[5], '2001')
self.assertEqual(goto[-2:], ['goto', '2010'])
self.assertEqual(source[5], '2002')
self.assertEqual(default[5], '2004')
def testReplacementInterfaceCannotReceiveDnsOrBeDeleted(self):
self.host.index = 77
link = {'ifname': 'utun101', 'ifindex': 78}
with mock.patch.object(
self.module, '_command', return_value=json.dumps([link])
), mock.patch.object(self.host, 'execute') as execute:
with self.assertRaisesRegex(RuntimeError, 'Conflicting'):
self.host.cleanup()
with self.assertRaisesRegex(RuntimeError, 'Conflicting'):
self.host.applyDNS('1.1.1.1')
execute.assert_not_called()
def testFailedPreparationCannotClaimAnUncapturedInterface(self):
link = {'ifname': 'utun101', 'ifindex': 78}
with mock.patch.object(
self.module, '_command', return_value=json.dumps([link])
), mock.patch.object(self.host, 'execute') as execute:
with self.assertRaisesRegex(RuntimeError, 'identity was not captured'):
self.host.cleanup()
execute.assert_not_called()
self.assertIsNone(self.host.index)
def testIpRouteJsonFlagsAndSeparatePrefixLengthsMatchExactRules(self):
expected = {_linuxRuleSelectors(command) for command in self.host.rules}
records = (
(
'-4',
{
'priority': 2000,
'src': 'all',
'dst': '198.18.0.0',
'dstlen': 15,
'table': 123456,
},
),
(
'-4',
{
'priority': 2002,
'src': 'all',
'not': None,
'table': 'main',
'suppress_prefixlength': 0,
'dport': 53,
'dport_mask': '0xffff',
},
),
(
'-4',
{
'priority': 2003,
'src': 'all',
'not': None,
'iif': 'lo',
'table': 123456,
},
),
('-4', {'priority': 2003, 'src': '0.0.0.0', 'iif': 'lo', 'table': 123456}),
(
'-6',
{
'priority': 2002,
'src': 'fd00::',
'srclen': 64,
'iif': 'lo',
'table': 123456,
},
),
(
'-6',
{
'priority': 2001,
'src': 'all',
'iif': 'utun101',
'goto': 2010,
'unresolved': None,
},
),
(
'-6',
{'priority': 2003, 'src': '0', 'srclen': 1, 'iif': 'lo', 'goto': 2010},
),
('-6', {'priority': 2010, 'src': 'all', 'nop': None}),
)
for flag, record in records:
with self.subTest(rule=record):
command = _linuxRuleDelete(flag, record, 123456, 2000, 'utun101')
self.assertIn(_linuxRuleSelectors(command), expected)
with self.assertRaisesRegex(RuntimeError, 'Conflicting'):
_linuxRuleDelete(
'-4',
{
'priority': 2002,
'not': None,
'dport': 53,
'dport_mask': '0xff00',
'table': 'main',
},
123456,
2000,
'utun101',
)
def testKernelSuppressionFieldMatchesExactRule(self):
"""Use iproute2's JSON spelling, which differs from its CLI spelling."""
expected = {_linuxRuleSelectors(command) for command in self.host.rules}
rule = {
'priority': 2001,
'src': 'all',
'table': 123456,
'suppress_prefixlen': 0,
'protocol': 0,
}
command = _linuxRuleDelete('-4', rule, 123456, 2000, 'utun101')
self.assertIn(_linuxRuleSelectors(command), expected)
self.assertIn('suppress_prefixlength', command)
for value in (1, -1, '0'):
with self.subTest(value=value), self.assertRaisesRegex(
RuntimeError, 'Conflicting'
):
_linuxRuleDelete(
'-4', {**rule, 'suppress_prefixlen': value}, 123456, 2000, 'utun101'
)
def testCleanupUsesExactSelectorsAndRetainsRefusedCleanupForRetry(self):
plan = SingTUNHostPlan({}, platform='Linux')
plan._linux = self.host
plan._activated = True
link = {'ifname': 'utun101', 'ifindex': 77}
self.host.index = 77
present = True
rule = {
'priority': 2001,
'src': 'all',
'table': 123456,
'suppress_prefixlen': 0,
'protocol': 0,
}
def command(arguments):
if arguments == ['ip', '-N', '-j', 'link', 'show']:
return json.dumps([link] if present else [])
if arguments == ['ip', '-4', '-N', '-j', 'rule', 'show']:
return json.dumps([rule] if present else [])
return '[]'
with mock.patch.object(
self.module, '_command', side_effect=command
), mock.patch.object(self.host, 'restoreDNS'), mock.patch.object(
self.host, 'execute', side_effect=RuntimeError('authorization denied')
):
with self.assertRaisesRegex(RuntimeError, 'authorization denied'):
plan.cleanup(False)
self.assertTrue(plan._activated)
self.assertFalse(plan._cleanupComplete)
def release(commands, **kwargs):
nonlocal present
self.assertTrue(kwargs['guard'])
self.assertEqual(commands[-1], ['ip', 'link', 'delete', 'dev', 'utun101'])
self.assertIn('suppress_prefixlength', commands[0])
self.assertIn('123456', commands[0])
self.assertNotIn('flush', str(commands))
present = False
with mock.patch.object(
self.module, '_command', side_effect=command
), mock.patch.object(self.host, 'restoreDNS'), mock.patch.object(
self.host, 'execute', side_effect=release
) as execute:
plan.cleanup(False)
plan.cleanup(True)
execute.assert_called_once()
self.assertFalse(plan._activated)
self.assertTrue(plan._cleanupComplete)
if __name__ == '__main__':
unittest.main()