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
logger.addHandler(syslogger)
192
("Mandos [%(process)d]: %(levelname)s:"
194
log.addHandler(syslogger)
197
197
console = logging.StreamHandler()
198
console.setFormatter(logging.Formatter('%(asctime)s %(name)s'
202
logger.addHandler(console)
203
logger.setLevel(level)
198
console.setFormatter(logging.Formatter("%(asctime)s %(name)s"
202
log.addHandler(console)
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
logger.critical("No suitable Zeroconf service name found"
398
" after %i retries, exiting.",
397
log.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
logger.info("Changing Zeroconf service name to %r ...",
404
log.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
logger.info("Local Zeroconf service name collision.")
413
log.info("Local Zeroconf service name collision.")
414
414
return self.rename(remove=False)
416
logger.critical("D-Bus Exception", exc_info=error)
416
log.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
logger.debug("Adding Zeroconf service '%s' of type '%s' ...",
440
self.name, self.type)
438
"StateChanged", self.entry_group_state_changed))
439
log.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
logger.debug("Avahi entry group state change: %i", state)
453
log.debug("Avahi entry group state change: %i", state)
455
455
if state == avahi.ENTRY_GROUP_ESTABLISHED:
456
logger.debug("Zeroconf service established.")
456
log.debug("Zeroconf service established.")
457
457
elif state == avahi.ENTRY_GROUP_COLLISION:
458
logger.info("Zeroconf service name collision.")
458
log.info("Zeroconf service name collision.")
460
460
elif state == avahi.ENTRY_GROUP_FAILURE:
461
logger.critical("Avahi: Error in group state changed %s",
461
log.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
logger.debug("Avahi server state change: %i", state)
478
log.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
logger.info("Local Zeroconf service name"
498
log.info("Local Zeroconf service name collision.")
500
499
return self.rename(remove=False)
502
logger.critical("D-Bus Exception", exc_info=error)
501
log.critical("D-Bus Exception", exc_info=error)
506
505
if error is None:
507
logger.debug("Unknown state: %r", state)
506
log.debug("Unknown state: %r", state)
509
logger.debug("Unknown state: %r: %r", state, error)
508
log.debug("Unknown state: %r: %r", state, error)
511
510
def activate(self):
512
511
"""Derived from the Avahi example code"""
686
685
def _retry_on_error(result, func, arguments,
687
686
_error_code=_error_code):
688
687
"""A function to retry on some errors, suitable
689
for the 'errcheck' attribute on ctypes functions"""
688
for the "errcheck" attribute on ctypes functions"""
690
689
while result < gnutls.E_SUCCESS:
691
690
if result not in (gnutls.E_INTERRUPTED, gnutls.E_AGAIN):
692
691
return _error_code(result)
876
875
"""A representation of a client host served by this server.
879
approved: bool(); 'None' if not yet approved/disapproved
878
approved: bool(); None if not yet approved/disapproved
880
879
approval_delay: datetime.timedelta(); Time to wait for approval
881
880
approval_duration: datetime.timedelta(); Duration of one approval
882
881
checker: multiprocessing.Process(); a running checker process used
883
to see if the client lives. 'None' if no process is
882
to see if the client lives. None if no process is
885
884
checker_callback_tag: a GLib event source tag, or None
886
885
checker_command: string; External command which is run to check
1010
1009
self.last_enabled = None
1011
1010
self.expires = None
1013
logger.debug("Creating client %r", self.name)
1014
logger.debug(" Key ID: %s", self.key_id)
1015
logger.debug(" Fingerprint: %s", self.fingerprint)
1012
log.debug("Creating client %r", self.name)
1013
log.debug(" Key ID: %s", self.key_id)
1014
log.debug(" Fingerprint: %s", self.fingerprint)
1016
1015
self.created = settings.get("created",
1017
1016
datetime.datetime.utcnow())
1057
1056
if not getattr(self, "enabled", False):
1060
logger.info("Disabling client %s", self.name)
1059
log.info("Disabling client %s", self.name)
1061
1060
if getattr(self, "disable_initiator_tag", None) is not None:
1062
1061
GLib.source_remove(self.disable_initiator_tag)
1063
1062
self.disable_initiator_tag = None
1107
1106
self.last_checker_status = returncode
1108
1107
self.last_checker_signal = None
1109
1108
if self.last_checker_status == 0:
1110
logger.info("Checker for %(name)s succeeded",
1109
log.info("Checker for %(name)s succeeded", vars(self))
1112
1110
self.checked_ok()
1114
logger.info("Checker for %(name)s failed", vars(self))
1112
log.info("Checker for %(name)s failed", vars(self))
1116
1114
self.last_checker_status = -1
1117
1115
self.last_checker_signal = -returncode
1118
logger.warning("Checker for %(name)s crashed?",
1116
log.warning("Checker for %(name)s crashed?", vars(self))
1122
1119
def checked_ok(self):
1169
1166
command = self.checker_command % escaped_attrs
1170
1167
except TypeError as error:
1171
logger.error('Could not format string "%s"',
1172
self.checker_command,
1168
log.error('Could not format string "%s"',
1169
self.checker_command, exc_info=error)
1174
1170
return True # Try again later
1175
1171
self.current_checker_command = command
1176
logger.info("Starting checker %r for %s", command,
1172
log.info("Starting checker %r for %s", command, self.name)
1178
1173
# We don't need to redirect stdout and stderr, since
1179
1174
# in normal mode, that is already done by daemon(),
1180
1175
# and in debug mode we don't want to. (Stdin is
1338
1333
@dbus.service.method(dbus.INTROSPECTABLE_IFACE,
1339
1334
out_signature="s",
1340
path_keyword='object_path',
1341
connection_keyword='connection')
1335
path_keyword="object_path",
1336
connection_keyword="connection")
1342
1337
def Introspect(self, object_path, connection):
1343
1338
"""Overloading of standard D-Bus method.
1498
1493
@dbus.service.method(dbus.INTROSPECTABLE_IFACE,
1499
1494
out_signature="s",
1500
path_keyword='object_path',
1501
connection_keyword='connection')
1495
path_keyword="object_path",
1496
connection_keyword="connection")
1502
1497
def Introspect(self, object_path, connection):
1503
1498
"""Overloading of standard D-Bus method.
1600
1595
@dbus.service.method(dbus.INTROSPECTABLE_IFACE,
1601
1596
out_signature="s",
1602
path_keyword='object_path',
1603
connection_keyword='connection')
1597
path_keyword="object_path",
1598
connection_keyword="connection")
1604
1599
def Introspect(self, object_path, connection):
1605
1600
"""Overloading of standard D-Bus method.
2272
2267
class ProxyClient:
2273
2268
def __init__(self, child_pipe, key_id, fpr, address):
2274
2269
self._pipe = child_pipe
2275
self._pipe.send(('init', key_id, fpr, address))
2270
self._pipe.send(("init", key_id, fpr, address))
2276
2271
if not self._pipe.recv():
2277
2272
raise KeyError(key_id or fpr)
2279
2274
def __getattribute__(self, name):
2281
2276
return super(ProxyClient, self).__getattribute__(name)
2282
self._pipe.send(('getattr', name))
2277
self._pipe.send(("getattr", name))
2283
2278
data = self._pipe.recv()
2284
if data[0] == 'data':
2279
if data[0] == "data":
2286
if data[0] == 'function':
2281
if data[0] == "function":
2288
2283
def func(*args, **kwargs):
2289
self._pipe.send(('funcall', name, args, kwargs))
2284
self._pipe.send(("funcall", name, args, kwargs))
2290
2285
return self._pipe.recv()[1]
2294
2289
def __setattr__(self, name, value):
2296
2291
return super(ProxyClient, self).__setattr__(name, value)
2297
self._pipe.send(('setattr', name, value))
2292
self._pipe.send(("setattr", name, value))
2300
2295
class ClientHandler(socketserver.BaseRequestHandler, object):
2306
2301
def handle(self):
2307
2302
with contextlib.closing(self.server.child_pipe) as child_pipe:
2308
logger.info("TCP connection from: %s",
2309
str(self.client_address))
2310
logger.debug("Pipe FD: %d",
2311
self.server.child_pipe.fileno())
2303
log.info("TCP connection from: %s",
2304
str(self.client_address))
2305
log.debug("Pipe FD: %d", self.server.child_pipe.fileno())
2313
2307
session = gnutls.ClientSession(self.request)
2315
# priority = ':'.join(("NONE", "+VERS-TLS1.1",
2309
# priority = ":".join(("NONE", "+VERS-TLS1.1",
2316
2310
# "+AES-256-CBC", "+SHA1",
2317
2311
# "+COMP-NULL", "+CTYPE-OPENPGP",
2326
2320
# Start communication using the Mandos protocol
2327
2321
# Get protocol number
2328
2322
line = self.request.makefile().readline()
2329
logger.debug("Protocol version: %r", line)
2323
log.debug("Protocol version: %r", line)
2331
2325
if int(line.strip().split()[0]) > 1:
2332
2326
raise RuntimeError(line)
2333
2327
except (ValueError, IndexError, RuntimeError) as error:
2334
logger.error("Unknown protocol version: %s", error)
2328
log.error("Unknown protocol version: %s", error)
2337
2331
# Start GnuTLS connection
2339
2333
session.handshake()
2340
2334
except gnutls.Error as error:
2341
logger.warning("Handshake failed: %s", error)
2335
log.warning("Handshake failed: %s", error)
2342
2336
# Do not run session.bye() here: the session is not
2343
2337
# established. Just abandon the request.
2345
logger.debug("Handshake succeeded")
2339
log.debug("Handshake succeeded")
2347
2341
approval_required = False
2364
2358
fpr = self.fingerprint(
2365
2359
self.peer_certificate(session))
2366
2360
except (TypeError, gnutls.Error) as error:
2367
logger.warning("Bad certificate: %s", error)
2361
log.warning("Bad certificate: %s", error)
2369
logger.debug("Fingerprint: %s", fpr)
2363
log.debug("Fingerprint: %s", fpr)
2372
2366
client = ProxyClient(child_pipe, key_id, fpr,
2392
2385
# We are approved or approval is disabled
2394
2387
elif client.approved is None:
2395
logger.info("Client %s needs approval",
2388
log.info("Client %s needs approval",
2397
2390
if self.server.use_dbus:
2398
2391
# Emit D-Bus signal
2399
2392
client.NeedApproval(
2400
2393
client.approval_delay.total_seconds()
2401
2394
* 1000, client.approved_by_default)
2403
logger.warning("Client %s was not approved",
2396
log.warning("Client %s was not approved",
2405
2398
if self.server.use_dbus:
2406
2399
# Emit D-Bus signal
2407
2400
client.Rejected("Denied")
2431
2424
session.send(client.secret)
2432
2425
except gnutls.Error as error:
2433
logger.warning("gnutls send failed",
2426
log.warning("gnutls send failed", exc_info=error)
2437
logger.info("Sending secret to %s", client.name)
2429
log.info("Sending secret to %s", client.name)
2438
2430
# bump the timeout using extended_timeout
2439
2431
client.bump_timeout(client.extended_timeout)
2440
2432
if self.server.use_dbus:
2464
2455
valid_cert_types = frozenset((gnutls.CRT_OPENPGP,))
2465
2456
# If not a valid certificate type...
2466
2457
if cert_type not in valid_cert_types:
2467
logger.info("Cert type %r not in %r", cert_type,
2458
log.info("Cert type %r not in %r", cert_type,
2469
2460
# ...return invalid data
2471
2462
list_size = ctypes.c_uint(1)
2655
2646
(self.interface + "\0").encode("utf-8"))
2656
2647
except socket.error as error:
2657
2648
if error.errno == errno.EPERM:
2658
logger.error("No permission to bind to"
2659
" interface %s", self.interface)
2649
log.error("No permission to bind to interface %s",
2660
2651
elif error.errno == errno.ENOPROTOOPT:
2661
logger.error("SO_BINDTODEVICE not available;"
2662
" cannot bind to interface %s",
2652
log.error("SO_BINDTODEVICE not available; cannot"
2653
" bind to interface %s", self.interface)
2664
2654
elif error.errno == errno.ENODEV:
2665
logger.error("Interface %s does not exist,"
2666
" cannot bind", self.interface)
2655
log.error("Interface %s does not exist, cannot"
2656
" bind", self.interface)
2669
2659
# Only bind(2) the socket if we really need to.
2767
logger.info("Client not found for key ID: %s, address"
2768
": %s", key_id or fpr, address)
2757
log.info("Client not found for key ID: %s, address:"
2758
" %s", key_id or fpr, address)
2769
2759
if self.use_dbus:
2770
2760
# Emit D-Bus signal
2771
2761
mandos_dbus_service.ClientNotFound(key_id or fpr,
2784
2774
# remove the old hook in favor of the new above hook on
2787
if command == 'funcall':
2777
if command == "funcall":
2788
2778
funcname = request[1]
2789
2779
args = request[2]
2790
2780
kwargs = request[3]
2792
parent_pipe.send(('data', getattr(client_object,
2782
parent_pipe.send(("data", getattr(client_object,
2793
2783
funcname)(*args,
2796
if command == 'getattr':
2786
if command == "getattr":
2797
2787
attrname = request[1]
2798
2788
if isinstance(client_object.__getattribute__(attrname),
2799
2789
collections.abc.Callable):
2800
parent_pipe.send(('function', ))
2790
parent_pipe.send(("function", ))
2802
2792
parent_pipe.send((
2803
'data', client_object.__getattribute__(attrname)))
2793
"data", client_object.__getattribute__(attrname)))
2805
if command == 'setattr':
2795
if command == "setattr":
2806
2796
attrname = request[1]
2807
2797
value = request[2]
2808
2798
setattr(client_object, attrname, value)
2914
2904
def string_to_delta(interval):
2915
2905
"""Parse a string and return a datetime.timedelta
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)
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)
3166
3156
pidfile = codecs.open(pidfilename, "w", encoding="utf-8")
3167
3157
except IOError as e:
3168
logger.error("Could not open file %r", pidfilename,
3158
log.error("Could not open file %r", pidfilename,
3171
3161
for name, group in (("_mandos", "_mandos"),
3172
3162
("mandos", "mandos"),
3187
logger.debug("Did setuid/setgid to {}:{}".format(uid,
3176
log.debug("Did setuid/setgid to %s:%s", uid, gid)
3189
3177
except OSError as error:
3190
logger.warning("Failed to setuid/setgid to {}:{}: {}"
3191
.format(uid, gid, os.strerror(error.errno)))
3178
log.warning("Failed to setuid/setgid to %s:%s: %s", uid, gid,
3179
os.strerror(error.errno))
3192
3180
if error.errno != errno.EPERM:
3329
3316
os.remove(stored_state_path)
3330
3317
except IOError as e:
3331
3318
if e.errno == errno.ENOENT:
3332
logger.warning("Could not load persistent state:"
3333
" {}".format(os.strerror(e.errno)))
3319
log.warning("Could not load persistent state:"
3320
" %s", os.strerror(e.errno))
3335
logger.critical("Could not load persistent state:",
3322
log.critical("Could not load persistent state:",
3338
3325
except EOFError as e:
3339
logger.warning("Could not load persistent state: "
3326
log.warning("Could not load persistent state: EOFError:",
3343
3329
with PGPEngine() as pgp:
3344
3330
for client_name, client in clients_data.items():
3371
3357
if client["enabled"]:
3372
3358
if datetime.datetime.utcnow() >= client["expires"]:
3373
3359
if not client["last_checked_ok"]:
3375
"disabling client {} - Client never "
3376
"performed a successful checker".format(
3360
log.warning("disabling client %s - Client"
3361
" never performed a successful"
3362
" checker", client_name)
3378
3363
client["enabled"] = False
3379
3364
elif client["last_checker_status"] != 0:
3381
"disabling client {} - Client last"
3382
" checker failed with error code"
3385
client["last_checker_status"]))
3365
log.warning("disabling client %s - Client"
3366
" last checker failed with error"
3367
" code %s", client_name,
3368
client["last_checker_status"])
3386
3369
client["enabled"] = False
3388
3371
client["expires"] = (
3389
3372
datetime.datetime.utcnow()
3390
3373
+ client["timeout"])
3391
logger.debug("Last checker succeeded,"
3392
" keeping {} enabled".format(
3374
log.debug("Last checker succeeded, keeping %s"
3375
" enabled", client_name)
3395
3377
client["secret"] = pgp.decrypt(
3396
3378
client["encrypted_secret"],
3397
3379
client_settings[client_name]["secret"])
3398
3380
except PGPError:
3399
3381
# If decryption fails, we use secret from new settings
3400
logger.debug("Failed to decrypt {} old secret".format(
3382
log.debug("Failed to decrypt %s old secret",
3402
3384
client["secret"] = (client_settings[client_name]
3600
3582
except NameError:
3602
3584
if e.errno in (errno.ENOENT, errno.EACCES, errno.EEXIST):
3603
logger.warning("Could not save persistent state: {}"
3604
.format(os.strerror(e.errno)))
3585
log.warning("Could not save persistent state: %s",
3586
os.strerror(e.errno))
3606
logger.warning("Could not save persistent state:",
3588
log.warning("Could not save persistent state:",
3610
3592
# Delete all clients, and settings from config
3637
3619
service.port = tcp_server.socket.getsockname()[1]
3639
logger.info("Now listening on address %r, port %d,"
3640
" flowinfo %d, scope_id %d",
3641
*tcp_server.socket.getsockname())
3621
log.info("Now listening on address %r, port %d, flowinfo %d,"
3622
" scope_id %d", *tcp_server.socket.getsockname())
3643
logger.info("Now listening on address %r, port %d",
3644
*tcp_server.socket.getsockname())
3624
log.info("Now listening on address %r, port %d",
3625
*tcp_server.socket.getsockname())
3646
3627
# service.interface = tcp_server.socket.getsockname()[3]
3662
3643
lambda *args, **kwargs: (tcp_server.handle_request
3663
3644
(*args[2:], **kwargs) or True))
3665
logger.debug("Starting main loop")
3646
log.debug("Starting main loop")
3666
3647
main_loop.run()
3667
3648
except AvahiError as error:
3668
logger.critical("Avahi Error", exc_info=error)
3649
log.critical("Avahi Error", exc_info=error)
3671
3652
except KeyboardInterrupt:
3673
3654
print("", file=sys.stderr)
3674
logger.debug("Server received KeyboardInterrupt")
3675
logger.debug("Server exiting")
3655
log.debug("Server received KeyboardInterrupt")
3656
log.debug("Server exiting")
3676
3657
# Must run before the D-Bus bus name gets deregistered
3680
def should_only_run_tests():
3661
def parse_test_args():
3662
# type: () -> argparse.Namespace
3681
3663
parser = argparse.ArgumentParser(add_help=False)
3682
parser.add_argument("--check", action='store_true')
3664
parser.add_argument("--check", action="store_true")
3665
parser.add_argument("--prefix", )
3683
3666
args, unknown_args = parser.parse_known_args()
3684
run_tests = args.check
3686
# Remove --check argument from sys.argv
3668
# Remove test options from sys.argv
3687
3669
sys.argv[1:] = unknown_args
3690
3672
# Add all tests from doctest strings
3691
3673
def load_tests(loader, tests, none):
3693
3675
tests.addTests(doctest.DocTestSuite())
3696
if __name__ == '__main__':
3678
if __name__ == "__main__":
3679
options = parse_test_args()
3698
if should_only_run_tests():
3699
# Call using ./mandos --check [--verbose]
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=[""])
3704
3696
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))