189
189
facility=logging.handlers.SysLogHandler.LOG_DAEMON,
190
190
address="/dev/log"))
191
191
syslogger.setFormatter(logging.Formatter
192
("Mandos [%(process)d]: %(levelname)s:"
194
log.addHandler(syslogger)
192
('Mandos [%(process)d]: %(levelname)s:'
194
logger.addHandler(syslogger)
197
197
console = logging.StreamHandler()
198
console.setFormatter(logging.Formatter("%(asctime)s %(name)s"
202
log.addHandler(console)
198
console.setFormatter(logging.Formatter('%(asctime)s %(name)s'
202
logger.addHandler(console)
203
logger.setLevel(level)
206
206
class PGPError(Exception):
224
224
except OSError as e:
225
225
if e.errno != errno.ENOENT:
227
self.gnupgargs = ["--batch",
228
"--homedir", self.tempdir,
227
self.gnupgargs = ['--batch',
228
'--homedir', self.tempdir,
231
231
# Only GPG version 1 has the --no-use-agent option.
232
232
if self.gpg == b"gpg" or self.gpg.endswith(b"/gpg"):
233
233
self.gnupgargs.append("--no-use-agent")
351
351
interface: integer; avahi.IF_UNSPEC or an interface index.
352
352
Used to optionally bind to the specified interface.
353
name: string; Example: "Mandos"
354
type: string; Example: "_mandos._tcp".
353
name: string; Example: 'Mandos'
354
type: string; Example: '_mandos._tcp'.
355
355
See <https://www.iana.org/assignments/service-names-port-numbers>
356
356
port: integer; what port to announce
357
357
TXT: list of strings; TXT record for the service
394
394
def rename(self, remove=True):
395
395
"""Derived from the Avahi example code"""
396
396
if self.rename_count >= self.max_renames:
397
log.critical("No suitable Zeroconf service name found"
398
" after %i retries, exiting.",
397
logger.critical("No suitable Zeroconf service name found"
398
" after %i retries, exiting.",
400
400
raise AvahiServiceError("Too many renames")
402
402
self.server.GetAlternativeServiceName(self.name))
403
403
self.rename_count += 1
404
log.info("Changing Zeroconf service name to %r ...",
404
logger.info("Changing Zeroconf service name to %r ...",
410
410
except dbus.exceptions.DBusException as error:
411
411
if (error.get_dbus_name()
412
412
== "org.freedesktop.Avahi.CollisionError"):
413
log.info("Local Zeroconf service name collision.")
413
logger.info("Local Zeroconf service name collision.")
414
414
return self.rename(remove=False)
416
log.critical("D-Bus Exception", exc_info=error)
416
logger.critical("D-Bus Exception", exc_info=error)
435
435
avahi.DBUS_INTERFACE_ENTRY_GROUP)
436
436
self.entry_group_state_changed_match = (
437
437
self.group.connect_to_signal(
438
"StateChanged", self.entry_group_state_changed))
439
log.debug("Adding Zeroconf service '%s' of type '%s' ...",
440
self.name, self.type)
438
'StateChanged', self.entry_group_state_changed))
439
logger.debug("Adding Zeroconf service '%s' of type '%s' ...",
440
self.name, self.type)
441
441
self.group.AddService(
451
451
def entry_group_state_changed(self, state, error):
452
452
"""Derived from the Avahi example code"""
453
log.debug("Avahi entry group state change: %i", state)
453
logger.debug("Avahi entry group state change: %i", state)
455
455
if state == avahi.ENTRY_GROUP_ESTABLISHED:
456
log.debug("Zeroconf service established.")
456
logger.debug("Zeroconf service established.")
457
457
elif state == avahi.ENTRY_GROUP_COLLISION:
458
log.info("Zeroconf service name collision.")
458
logger.info("Zeroconf service name collision.")
460
460
elif state == avahi.ENTRY_GROUP_FAILURE:
461
log.critical("Avahi: Error in group state changed %s",
461
logger.critical("Avahi: Error in group state changed %s",
463
463
raise AvahiGroupError("State changed: {!s}".format(error))
465
465
def cleanup(self):
476
476
def server_state_changed(self, state, error=None):
477
477
"""Derived from the Avahi example code"""
478
log.debug("Avahi server state change: %i", state)
478
logger.debug("Avahi server state change: %i", state)
480
480
avahi.SERVER_INVALID: "Zeroconf server invalid",
481
481
avahi.SERVER_REGISTERING: None,
495
495
except dbus.exceptions.DBusException as error:
496
496
if (error.get_dbus_name()
497
497
== "org.freedesktop.Avahi.CollisionError"):
498
log.info("Local Zeroconf service name collision.")
498
logger.info("Local Zeroconf service name"
499
500
return self.rename(remove=False)
501
log.critical("D-Bus Exception", exc_info=error)
502
logger.critical("D-Bus Exception", exc_info=error)
505
506
if error is None:
506
log.debug("Unknown state: %r", state)
507
logger.debug("Unknown state: %r", state)
508
log.debug("Unknown state: %r: %r", state, error)
509
logger.debug("Unknown state: %r: %r", state, error)
510
511
def activate(self):
511
512
"""Derived from the Avahi example code"""
685
686
def _retry_on_error(result, func, arguments,
686
687
_error_code=_error_code):
687
688
"""A function to retry on some errors, suitable
688
for the "errcheck" attribute on ctypes functions"""
689
for the 'errcheck' attribute on ctypes functions"""
689
690
while result < gnutls.E_SUCCESS:
690
691
if result not in (gnutls.E_INTERRUPTED, gnutls.E_AGAIN):
691
692
return _error_code(result)
875
876
"""A representation of a client host served by this server.
878
approved: bool(); None if not yet approved/disapproved
879
approved: bool(); 'None' if not yet approved/disapproved
879
880
approval_delay: datetime.timedelta(); Time to wait for approval
880
881
approval_duration: datetime.timedelta(); Duration of one approval
881
882
checker: multiprocessing.Process(); a running checker process used
882
to see if the client lives. None if no process is
883
to see if the client lives. 'None' if no process is
884
885
checker_callback_tag: a GLib event source tag, or None
885
886
checker_command: string; External command which is run to check
1009
1010
self.last_enabled = None
1010
1011
self.expires = None
1012
log.debug("Creating client %r", self.name)
1013
log.debug(" Key ID: %s", self.key_id)
1014
log.debug(" Fingerprint: %s", self.fingerprint)
1013
logger.debug("Creating client %r", self.name)
1014
logger.debug(" Key ID: %s", self.key_id)
1015
logger.debug(" Fingerprint: %s", self.fingerprint)
1015
1016
self.created = settings.get("created",
1016
1017
datetime.datetime.utcnow())
1056
1057
if not getattr(self, "enabled", False):
1059
log.info("Disabling client %s", self.name)
1060
logger.info("Disabling client %s", self.name)
1060
1061
if getattr(self, "disable_initiator_tag", None) is not None:
1061
1062
GLib.source_remove(self.disable_initiator_tag)
1062
1063
self.disable_initiator_tag = None
1106
1107
self.last_checker_status = returncode
1107
1108
self.last_checker_signal = None
1108
1109
if self.last_checker_status == 0:
1109
log.info("Checker for %(name)s succeeded", vars(self))
1110
logger.info("Checker for %(name)s succeeded",
1110
1112
self.checked_ok()
1112
log.info("Checker for %(name)s failed", vars(self))
1114
logger.info("Checker for %(name)s failed", vars(self))
1114
1116
self.last_checker_status = -1
1115
1117
self.last_checker_signal = -returncode
1116
log.warning("Checker for %(name)s crashed?", vars(self))
1118
logger.warning("Checker for %(name)s crashed?",
1119
1122
def checked_ok(self):
1166
1169
command = self.checker_command % escaped_attrs
1167
1170
except TypeError as error:
1168
log.error('Could not format string "%s"',
1169
self.checker_command, exc_info=error)
1171
logger.error('Could not format string "%s"',
1172
self.checker_command,
1170
1174
return True # Try again later
1171
1175
self.current_checker_command = command
1172
log.info("Starting checker %r for %s", command, self.name)
1176
logger.info("Starting checker %r for %s", command,
1173
1178
# We don't need to redirect stdout and stderr, since
1174
1179
# in normal mode, that is already done by daemon(),
1175
1180
# and in debug mode we don't want to. (Stdin is
1333
1338
@dbus.service.method(dbus.INTROSPECTABLE_IFACE,
1334
1339
out_signature="s",
1335
path_keyword="object_path",
1336
connection_keyword="connection")
1340
path_keyword='object_path',
1341
connection_keyword='connection')
1337
1342
def Introspect(self, object_path, connection):
1338
1343
"""Overloading of standard D-Bus method.
1493
1498
@dbus.service.method(dbus.INTROSPECTABLE_IFACE,
1494
1499
out_signature="s",
1495
path_keyword="object_path",
1496
connection_keyword="connection")
1500
path_keyword='object_path',
1501
connection_keyword='connection')
1497
1502
def Introspect(self, object_path, connection):
1498
1503
"""Overloading of standard D-Bus method.
1595
1600
@dbus.service.method(dbus.INTROSPECTABLE_IFACE,
1596
1601
out_signature="s",
1597
path_keyword="object_path",
1598
connection_keyword="connection")
1602
path_keyword='object_path',
1603
connection_keyword='connection')
1599
1604
def Introspect(self, object_path, connection):
1600
1605
"""Overloading of standard D-Bus method.
2267
2272
class ProxyClient:
2268
2273
def __init__(self, child_pipe, key_id, fpr, address):
2269
2274
self._pipe = child_pipe
2270
self._pipe.send(("init", key_id, fpr, address))
2275
self._pipe.send(('init', key_id, fpr, address))
2271
2276
if not self._pipe.recv():
2272
2277
raise KeyError(key_id or fpr)
2274
2279
def __getattribute__(self, name):
2276
2281
return super(ProxyClient, self).__getattribute__(name)
2277
self._pipe.send(("getattr", name))
2282
self._pipe.send(('getattr', name))
2278
2283
data = self._pipe.recv()
2279
if data[0] == "data":
2284
if data[0] == 'data':
2281
if data[0] == "function":
2286
if data[0] == 'function':
2283
2288
def func(*args, **kwargs):
2284
self._pipe.send(("funcall", name, args, kwargs))
2289
self._pipe.send(('funcall', name, args, kwargs))
2285
2290
return self._pipe.recv()[1]
2289
2294
def __setattr__(self, name, value):
2291
2296
return super(ProxyClient, self).__setattr__(name, value)
2292
self._pipe.send(("setattr", name, value))
2297
self._pipe.send(('setattr', name, value))
2295
2300
class ClientHandler(socketserver.BaseRequestHandler, object):
2301
2306
def handle(self):
2302
2307
with contextlib.closing(self.server.child_pipe) as child_pipe:
2303
log.info("TCP connection from: %s",
2304
str(self.client_address))
2305
log.debug("Pipe FD: %d", self.server.child_pipe.fileno())
2308
logger.info("TCP connection from: %s",
2309
str(self.client_address))
2310
logger.debug("Pipe FD: %d",
2311
self.server.child_pipe.fileno())
2307
2313
session = gnutls.ClientSession(self.request)
2309
# priority = ":".join(("NONE", "+VERS-TLS1.1",
2315
# priority = ':'.join(("NONE", "+VERS-TLS1.1",
2310
2316
# "+AES-256-CBC", "+SHA1",
2311
2317
# "+COMP-NULL", "+CTYPE-OPENPGP",
2320
2326
# Start communication using the Mandos protocol
2321
2327
# Get protocol number
2322
2328
line = self.request.makefile().readline()
2323
log.debug("Protocol version: %r", line)
2329
logger.debug("Protocol version: %r", line)
2325
2331
if int(line.strip().split()[0]) > 1:
2326
2332
raise RuntimeError(line)
2327
2333
except (ValueError, IndexError, RuntimeError) as error:
2328
log.error("Unknown protocol version: %s", error)
2334
logger.error("Unknown protocol version: %s", error)
2331
2337
# Start GnuTLS connection
2333
2339
session.handshake()
2334
2340
except gnutls.Error as error:
2335
log.warning("Handshake failed: %s", error)
2341
logger.warning("Handshake failed: %s", error)
2336
2342
# Do not run session.bye() here: the session is not
2337
2343
# established. Just abandon the request.
2339
log.debug("Handshake succeeded")
2345
logger.debug("Handshake succeeded")
2341
2347
approval_required = False
2358
2364
fpr = self.fingerprint(
2359
2365
self.peer_certificate(session))
2360
2366
except (TypeError, gnutls.Error) as error:
2361
log.warning("Bad certificate: %s", error)
2367
logger.warning("Bad certificate: %s", error)
2363
log.debug("Fingerprint: %s", fpr)
2369
logger.debug("Fingerprint: %s", fpr)
2366
2372
client = ProxyClient(child_pipe, key_id, fpr,
2385
2392
# We are approved or approval is disabled
2387
2394
elif client.approved is None:
2388
log.info("Client %s needs approval",
2395
logger.info("Client %s needs approval",
2390
2397
if self.server.use_dbus:
2391
2398
# Emit D-Bus signal
2392
2399
client.NeedApproval(
2393
2400
client.approval_delay.total_seconds()
2394
2401
* 1000, client.approved_by_default)
2396
log.warning("Client %s was not approved",
2403
logger.warning("Client %s was not approved",
2398
2405
if self.server.use_dbus:
2399
2406
# Emit D-Bus signal
2400
2407
client.Rejected("Denied")
2424
2431
session.send(client.secret)
2425
2432
except gnutls.Error as error:
2426
log.warning("gnutls send failed", exc_info=error)
2433
logger.warning("gnutls send failed",
2429
log.info("Sending secret to %s", client.name)
2437
logger.info("Sending secret to %s", client.name)
2430
2438
# bump the timeout using extended_timeout
2431
2439
client.bump_timeout(client.extended_timeout)
2432
2440
if self.server.use_dbus:
2455
2464
valid_cert_types = frozenset((gnutls.CRT_OPENPGP,))
2456
2465
# If not a valid certificate type...
2457
2466
if cert_type not in valid_cert_types:
2458
log.info("Cert type %r not in %r", cert_type,
2467
logger.info("Cert type %r not in %r", cert_type,
2460
2469
# ...return invalid data
2462
2471
list_size = ctypes.c_uint(1)
2646
2655
(self.interface + "\0").encode("utf-8"))
2647
2656
except socket.error as error:
2648
2657
if error.errno == errno.EPERM:
2649
log.error("No permission to bind to interface %s",
2658
logger.error("No permission to bind to"
2659
" interface %s", self.interface)
2651
2660
elif error.errno == errno.ENOPROTOOPT:
2652
log.error("SO_BINDTODEVICE not available; cannot"
2653
" bind to interface %s", self.interface)
2661
logger.error("SO_BINDTODEVICE not available;"
2662
" cannot bind to interface %s",
2654
2664
elif error.errno == errno.ENODEV:
2655
log.error("Interface %s does not exist, cannot"
2656
" bind", self.interface)
2665
logger.error("Interface %s does not exist,"
2666
" cannot bind", self.interface)
2659
2669
# Only bind(2) the socket if we really need to.
2757
log.info("Client not found for key ID: %s, address:"
2758
" %s", key_id or fpr, address)
2767
logger.info("Client not found for key ID: %s, address"
2768
": %s", key_id or fpr, address)
2759
2769
if self.use_dbus:
2760
2770
# Emit D-Bus signal
2761
2771
mandos_dbus_service.ClientNotFound(key_id or fpr,
2774
2784
# remove the old hook in favor of the new above hook on
2777
if command == "funcall":
2787
if command == 'funcall':
2778
2788
funcname = request[1]
2779
2789
args = request[2]
2780
2790
kwargs = request[3]
2782
parent_pipe.send(("data", getattr(client_object,
2792
parent_pipe.send(('data', getattr(client_object,
2783
2793
funcname)(*args,
2786
if command == "getattr":
2796
if command == 'getattr':
2787
2797
attrname = request[1]
2788
2798
if isinstance(client_object.__getattribute__(attrname),
2789
2799
collections.abc.Callable):
2790
parent_pipe.send(("function", ))
2800
parent_pipe.send(('function', ))
2792
2802
parent_pipe.send((
2793
"data", client_object.__getattribute__(attrname)))
2803
'data', client_object.__getattribute__(attrname)))
2795
if command == "setattr":
2805
if command == 'setattr':
2796
2806
attrname = request[1]
2797
2807
value = request[2]
2798
2808
setattr(client_object, attrname, value)
2904
2914
def string_to_delta(interval):
2905
2915
"""Parse a string and return a datetime.timedelta
2907
>>> string_to_delta("7d") == datetime.timedelta(7)
2909
>>> string_to_delta("60s") == datetime.timedelta(0, 60)
2911
>>> string_to_delta("60m") == datetime.timedelta(0, 3600)
2913
>>> string_to_delta("24h") == datetime.timedelta(1)
2915
>>> string_to_delta("1w") == datetime.timedelta(7)
2917
>>> string_to_delta("5m 30s") == datetime.timedelta(0, 330)
2917
>>> string_to_delta('7d') == datetime.timedelta(7)
2919
>>> string_to_delta('60s') == datetime.timedelta(0, 60)
2921
>>> string_to_delta('60m') == datetime.timedelta(0, 3600)
2923
>>> string_to_delta('24h') == datetime.timedelta(1)
2925
>>> string_to_delta('1w') == datetime.timedelta(7)
2927
>>> string_to_delta('5m 30s') == datetime.timedelta(0, 330)
3156
3166
pidfile = codecs.open(pidfilename, "w", encoding="utf-8")
3157
3167
except IOError as e:
3158
log.error("Could not open file %r", pidfilename,
3168
logger.error("Could not open file %r", pidfilename,
3161
3171
for name, group in (("_mandos", "_mandos"),
3162
3172
("mandos", "mandos"),
3176
log.debug("Did setuid/setgid to %s:%s", uid, gid)
3187
logger.debug("Did setuid/setgid to {}:{}".format(uid,
3177
3189
except OSError as error:
3178
log.warning("Failed to setuid/setgid to %s:%s: %s", uid, gid,
3179
os.strerror(error.errno))
3190
logger.warning("Failed to setuid/setgid to {}:{}: {}"
3191
.format(uid, gid, os.strerror(error.errno)))
3180
3192
if error.errno != errno.EPERM:
3316
3329
os.remove(stored_state_path)
3317
3330
except IOError as e:
3318
3331
if e.errno == errno.ENOENT:
3319
log.warning("Could not load persistent state:"
3320
" %s", os.strerror(e.errno))
3332
logger.warning("Could not load persistent state:"
3333
" {}".format(os.strerror(e.errno)))
3322
log.critical("Could not load persistent state:",
3335
logger.critical("Could not load persistent state:",
3325
3338
except EOFError as e:
3326
log.warning("Could not load persistent state: EOFError:",
3339
logger.warning("Could not load persistent state: "
3329
3343
with PGPEngine() as pgp:
3330
3344
for client_name, client in clients_data.items():
3357
3371
if client["enabled"]:
3358
3372
if datetime.datetime.utcnow() >= client["expires"]:
3359
3373
if not client["last_checked_ok"]:
3360
log.warning("disabling client %s - Client"
3361
" never performed a successful"
3362
" checker", client_name)
3375
"disabling client {} - Client never "
3376
"performed a successful checker".format(
3363
3378
client["enabled"] = False
3364
3379
elif client["last_checker_status"] != 0:
3365
log.warning("disabling client %s - Client"
3366
" last checker failed with error"
3367
" code %s", client_name,
3368
client["last_checker_status"])
3381
"disabling client {} - Client last"
3382
" checker failed with error code"
3385
client["last_checker_status"]))
3369
3386
client["enabled"] = False
3371
3388
client["expires"] = (
3372
3389
datetime.datetime.utcnow()
3373
3390
+ client["timeout"])
3374
log.debug("Last checker succeeded, keeping %s"
3375
" enabled", client_name)
3391
logger.debug("Last checker succeeded,"
3392
" keeping {} enabled".format(
3377
3395
client["secret"] = pgp.decrypt(
3378
3396
client["encrypted_secret"],
3379
3397
client_settings[client_name]["secret"])
3380
3398
except PGPError:
3381
3399
# If decryption fails, we use secret from new settings
3382
log.debug("Failed to decrypt %s old secret",
3400
logger.debug("Failed to decrypt {} old secret".format(
3384
3402
client["secret"] = (client_settings[client_name]
3582
3600
except NameError:
3584
3602
if e.errno in (errno.ENOENT, errno.EACCES, errno.EEXIST):
3585
log.warning("Could not save persistent state: %s",
3586
os.strerror(e.errno))
3603
logger.warning("Could not save persistent state: {}"
3604
.format(os.strerror(e.errno)))
3588
log.warning("Could not save persistent state:",
3606
logger.warning("Could not save persistent state:",
3592
3610
# Delete all clients, and settings from config
3619
3637
service.port = tcp_server.socket.getsockname()[1]
3621
log.info("Now listening on address %r, port %d, flowinfo %d,"
3622
" scope_id %d", *tcp_server.socket.getsockname())
3639
logger.info("Now listening on address %r, port %d,"
3640
" flowinfo %d, scope_id %d",
3641
*tcp_server.socket.getsockname())
3624
log.info("Now listening on address %r, port %d",
3625
*tcp_server.socket.getsockname())
3643
logger.info("Now listening on address %r, port %d",
3644
*tcp_server.socket.getsockname())
3627
3646
# service.interface = tcp_server.socket.getsockname()[3]
3643
3662
lambda *args, **kwargs: (tcp_server.handle_request
3644
3663
(*args[2:], **kwargs) or True))
3646
log.debug("Starting main loop")
3665
logger.debug("Starting main loop")
3647
3666
main_loop.run()
3648
3667
except AvahiError as error:
3649
log.critical("Avahi Error", exc_info=error)
3668
logger.critical("Avahi Error", exc_info=error)
3652
3671
except KeyboardInterrupt:
3654
3673
print("", file=sys.stderr)
3655
log.debug("Server received KeyboardInterrupt")
3656
log.debug("Server exiting")
3674
logger.debug("Server received KeyboardInterrupt")
3675
logger.debug("Server exiting")
3657
3676
# Must run before the D-Bus bus name gets deregistered
3661
def parse_test_args():
3662
# type: () -> argparse.Namespace
3680
def should_only_run_tests():
3663
3681
parser = argparse.ArgumentParser(add_help=False)
3664
parser.add_argument("--check", action="store_true")
3665
parser.add_argument("--prefix", )
3682
parser.add_argument("--check", action='store_true')
3666
3683
args, unknown_args = parser.parse_known_args()
3668
# Remove test options from sys.argv
3684
run_tests = args.check
3686
# Remove --check argument from sys.argv
3669
3687
sys.argv[1:] = unknown_args
3672
3690
# Add all tests from doctest strings
3673
3691
def load_tests(loader, tests, none):
3675
3693
tests.addTests(doctest.DocTestSuite())
3678
if __name__ == "__main__":
3679
options = parse_test_args()
3696
if __name__ == '__main__':
3682
extra_test_prefix = options.prefix
3683
if extra_test_prefix is not None:
3684
if not (unittest.main(argv=[""], exit=False)
3685
.result.wasSuccessful()):
3687
class ExtraTestLoader(unittest.TestLoader):
3688
testMethodPrefix = extra_test_prefix
3689
# Call using ./scriptname --test [--verbose]
3690
unittest.main(argv=[""], testLoader=ExtraTestLoader())
3692
unittest.main(argv=[""])
3698
if should_only_run_tests():
3699
# Call using ./mandos --check [--verbose]
3696
3704
logging.shutdown()
3700
# (lambda (&optional extra)
3701
# (if (not (funcall run-tests-in-test-buffer default-directory
3703
# (funcall show-test-buffer-in-test-window)
3704
# (funcall remove-test-window)
3705
# (if extra (message "Extra tests run successfully!"))))
3706
# run-tests-in-test-buffer:
3707
# (lambda (dir &optional extra)
3708
# (with-current-buffer (get-buffer-create "*Test*")
3709
# (setq buffer-read-only nil
3710
# default-directory dir)
3712
# (compilation-mode))
3713
# (let ((process-result
3714
# (let ((inhibit-read-only t))
3715
# (process-file-shell-command
3716
# (funcall get-command-line extra) nil "*Test*"))))
3717
# (and (numberp process-result)
3718
# (= process-result 0))))
3720
# (lambda (&optional extra)
3721
# (let ((quoted-script
3722
# (shell-quote-argument (funcall get-script-name))))
3724
# (concat "%s --check" (if extra " --prefix=atest" ""))
3728
# (if (fboundp 'file-local-name)
3729
# (file-local-name (buffer-file-name))
3730
# (or (file-remote-p (buffer-file-name) 'localname)
3731
# (buffer-file-name))))
3732
# remove-test-window:
3734
# (let ((test-window (get-buffer-window "*Test*")))
3735
# (if test-window (delete-window test-window))))
3736
# show-test-buffer-in-test-window:
3738
# (when (not (get-buffer-window-list "*Test*"))
3739
# (setq next-error-last-buffer (get-buffer "*Test*"))
3740
# (let* ((side (if (>= (window-width) 146) 'right 'bottom))
3741
# (display-buffer-overriding-action
3742
# `((display-buffer-in-side-window) (side . ,side)
3743
# (window-height . fit-window-to-buffer)
3744
# (window-width . fit-window-to-buffer))))
3745
# (display-buffer "*Test*"))))
3748
# (let* ((run-extra-tests (lambda () (interactive)
3749
# (funcall run-tests t)))
3750
# (inner-keymap `(keymap (116 . ,run-extra-tests))) ; t
3751
# (outer-keymap `(keymap (3 . ,inner-keymap)))) ; C-c
3752
# (setq minor-mode-overriding-map-alist
3753
# (cons `(run-tests . ,outer-keymap)
3754
# minor-mode-overriding-map-alist)))
3755
# (add-hook 'after-save-hook run-tests 90 t))