/mandos/release

To get this branch, use:
bzr branch http://bzr.recompile.se/loggerhead/mandos/release

« back to all changes in this revision

Viewing changes to mandos-ctl

  • Committer: teddy at recompile
  • Date: 2020-02-09 03:42:50 UTC
  • mto: This revision was merged to the branch mainline in revision 396.
  • Revision ID: teddy@recompile.se-20200209034250-dxkkuq0eevnzk8py
Update formatting in TODO file

* TODO: Add markup, links and a file-wide tag.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
1
#!/usr/bin/python3 -bbI
2
 
# -*- coding: utf-8; lexical-binding: t -*-
3
 
#
4
 
# Mandos Control - Control or query the Mandos server
5
 
#
6
 
# Copyright © 2008-2022 Teddy Hogeborn
7
 
# Copyright © 2008-2022 Björn Påhlsson
 
2
# -*- after-save-hook: (lambda () (let ((command (if (fboundp 'file-local-name) (file-local-name (buffer-file-name)) (or (file-remote-p (buffer-file-name) 'localname) (buffer-file-name))))) (if (= (progn (if (get-buffer "*Test*") (kill-buffer "*Test*")) (process-file-shell-command (format "%s --check" (shell-quote-argument command)) nil "*Test*")) 0) (let ((w (get-buffer-window "*Test*"))) (if w (delete-window w))) (progn (with-current-buffer "*Test*" (compilation-mode)) (display-buffer "*Test*" '(display-buffer-in-side-window)))))); coding: utf-8 -*-
 
3
#
 
4
# Mandos Monitor - Control and monitor the Mandos server
 
5
#
 
6
# Copyright © 2008-2019 Teddy Hogeborn
 
7
# Copyright © 2008-2019 Björn Påhlsson
8
8
#
9
9
# This file is part of Mandos.
10
10
#
23
23
#
24
24
# Contact the authors at <mandos@recompile.se>.
25
25
#
 
26
 
26
27
from __future__ import (division, absolute_import, print_function,
27
28
                        unicode_literals)
28
29
 
32
33
    pass
33
34
 
34
35
import sys
35
 
import unittest
36
36
import argparse
37
 
import logging
38
 
import os
39
37
import locale
40
38
import datetime
41
39
import re
 
40
import os
42
41
import collections
43
42
import json
 
43
import unittest
 
44
import logging
44
45
import io
45
46
import tempfile
46
47
import contextlib
48
49
if sys.version_info.major == 2:
49
50
    __metaclass__ = type
50
51
    str = unicode
51
 
    input = raw_input
52
 
 
53
52
 
54
53
class gi:
55
54
    """Dummy gi module, for the tests"""
57
56
        class GLib:
58
57
            class Error(Exception):
59
58
                pass
60
 
 
61
 
 
62
59
dbussy = None
63
60
ravel = None
64
61
dbus_python = None
80
77
    import warnings
81
78
    warnings.simplefilter("default")
82
79
 
83
 
log = logging.getLogger(os.path.basename(sys.argv[0]))
84
 
logging.basicConfig(level="INFO",         # Show info level messages
 
80
log = logging.getLogger(sys.argv[0])
 
81
logging.basicConfig(level="INFO", # Show info level messages
85
82
                    format="%(message)s") # Show basic log messages
86
83
 
87
84
logging.captureWarnings(True)   # Show warnings via the logging system
92
89
 
93
90
locale.setlocale(locale.LC_ALL, "")
94
91
 
95
 
version = "1.8.16"
 
92
version = "1.8.9"
96
93
 
97
94
 
98
95
def main():
105
102
    clientnames = options.client
106
103
 
107
104
    if options.debug:
108
 
        logging.getLogger("").setLevel(logging.DEBUG)
 
105
        log.setLevel(logging.DEBUG)
109
106
 
110
107
    if dbussy is not None and ravel is not None:
111
108
        bus = dbussy_adapter.CachingBus(dbussy, ravel)
259
256
        return rfc3339_duration_to_delta(interval)
260
257
    except ValueError as e:
261
258
        log.warning("%s - Parsing as pre-1.6.1 interval instead",
262
 
                    " ".join(e.args))
 
259
                    ' '.join(e.args))
263
260
    return parse_pre_1_6_1_interval(interval)
264
261
 
265
262
 
395
392
 
396
393
 
397
394
def parse_pre_1_6_1_interval(interval):
398
 
    r"""Parse an interval string as documented by Mandos before 1.6.1,
 
395
    """Parse an interval string as documented by Mandos before 1.6.1,
399
396
    and return a datetime.timedelta
400
397
 
401
 
    >>> parse_pre_1_6_1_interval("7d") == datetime.timedelta(days=7)
402
 
    True
403
 
    >>> parse_pre_1_6_1_interval("60s") == datetime.timedelta(0, 60)
404
 
    True
405
 
    >>> parse_pre_1_6_1_interval("60m") == datetime.timedelta(hours=1)
406
 
    True
407
 
    >>> parse_pre_1_6_1_interval("24h") == datetime.timedelta(days=1)
408
 
    True
409
 
    >>> parse_pre_1_6_1_interval("1w") == datetime.timedelta(days=7)
410
 
    True
411
 
    >>> parse_pre_1_6_1_interval("5m 30s") == datetime.timedelta(0, 330)
412
 
    True
413
 
    >>> parse_pre_1_6_1_interval("") == datetime.timedelta(0)
 
398
    >>> parse_pre_1_6_1_interval('7d') == datetime.timedelta(days=7)
 
399
    True
 
400
    >>> parse_pre_1_6_1_interval('60s') == datetime.timedelta(0, 60)
 
401
    True
 
402
    >>> parse_pre_1_6_1_interval('60m') == datetime.timedelta(hours=1)
 
403
    True
 
404
    >>> parse_pre_1_6_1_interval('24h') == datetime.timedelta(days=1)
 
405
    True
 
406
    >>> parse_pre_1_6_1_interval('1w') == datetime.timedelta(days=7)
 
407
    True
 
408
    >>> parse_pre_1_6_1_interval('5m 30s') == datetime.timedelta(0, 330)
 
409
    True
 
410
    >>> parse_pre_1_6_1_interval('') == datetime.timedelta(0)
414
411
    True
415
412
    >>> # Ignore unknown characters, allow any order and repetitions
416
 
    >>> parse_pre_1_6_1_interval("2dxy7zz11y3m5m") \
417
 
    ... == datetime.timedelta(2, 480, 18000)
 
413
    >>> parse_pre_1_6_1_interval('2dxy7zz11y3m5m') == datetime.timedelta(2, 480, 18000)
418
414
    True
419
415
 
420
416
    """
489
485
    class SystemBus:
490
486
 
491
487
        object_manager_iface = "org.freedesktop.DBus.ObjectManager"
492
 
 
493
488
        def get_managed_objects(self, busname, objectpath):
494
489
            return self.call_method("GetManagedObjects", busname,
495
490
                                    objectpath,
496
491
                                    self.object_manager_iface)
497
492
 
498
493
        properties_iface = "org.freedesktop.DBus.Properties"
499
 
 
500
494
        def set_property(self, busname, objectpath, interface, key,
501
495
                         value):
502
496
            self.call_method("Set", busname, objectpath,
507
501
                        interface, *args):
508
502
            raise NotImplementedError()
509
503
 
 
504
 
510
505
    class MandosBus(SystemBus):
511
506
        busname_domain = "se.recompile"
512
507
        busname = busname_domain + ".Mandos"
605
600
 
606
601
    class SilenceLogger:
607
602
        "Simple context manager to silence a particular logger"
608
 
 
609
603
        def __init__(self, loggername):
610
604
            self.logger = logging.getLogger(loggername)
611
605
 
621
615
        def __exit__(self, exc_type, exc_val, exc_tb):
622
616
            self.logger.removeFilter(self.nullfilter)
623
617
 
 
618
 
624
619
    class CachingBus(SystemBus):
625
620
        """A caching layer for dbus_python_adapter.SystemBus"""
626
 
 
627
621
        def __init__(self, *args, **kwargs):
628
622
            self.object_cache = {}
629
623
            super(dbus_python_adapter.CachingBus,
630
624
                  self).__init__(*args, **kwargs)
631
 
 
632
625
        def get_object(self, busname, objectpath):
633
626
            try:
634
627
                return self.object_cache[(busname, objectpath)]
636
629
                new_object = super(
637
630
                    dbus_python_adapter.CachingBus,
638
631
                    self).get_object(busname, objectpath)
639
 
                self.object_cache[(busname, objectpath)] = new_object
 
632
                self.object_cache[(busname, objectpath)]  = new_object
640
633
                return new_object
641
634
 
642
635
 
689
682
 
690
683
    class CachingBus(SystemBus):
691
684
        """A caching layer for pydbus_adapter.SystemBus"""
692
 
 
693
685
        def __init__(self, *args, **kwargs):
694
686
            self.object_cache = {}
695
687
            super(pydbus_adapter.CachingBus,
696
688
                  self).__init__(*args, **kwargs)
697
 
 
698
689
        def get(self, busname, objectpath):
699
690
            try:
700
691
                return self.object_cache[(busname, objectpath)]
701
692
            except KeyError:
702
693
                new_object = (super(pydbus_adapter.CachingBus, self)
703
694
                              .get(busname, objectpath))
704
 
                self.object_cache[(busname, objectpath)] = new_object
 
695
                self.object_cache[(busname, objectpath)]  = new_object
705
696
                return new_object
706
697
 
707
698
 
733
724
            iface = proxy_object.get_interface(interface)
734
725
            method = getattr(iface, methodname)
735
726
            with self.convert_exception(dbus.Error):
736
 
                value = method(*args)
 
727
                value =  method(*args)
737
728
            # DBussy returns values either as an empty list or as a
738
 
            # list of one element with the return value
 
729
            # tuple: (signature, value)
739
730
            if value:
740
731
                return self.type_filter(value[0])
741
732
 
747
738
 
748
739
        def type_filter(self, value):
749
740
            """Convert the most bothersome types to Python types"""
750
 
            # A D-Bus Variant value is represented as the Python type
751
 
            # Tuple[dbussy.DBUS.Signature, Any]
752
741
            if isinstance(value, tuple):
753
742
                if (len(value) == 2
754
743
                    and isinstance(value[0],
780
769
 
781
770
    class CachingBus(MandosBus):
782
771
        """A caching layer for dbussy_adapter.MandosBus"""
783
 
 
784
772
        def __init__(self, *args, **kwargs):
785
773
            self.object_cache = {}
786
774
            super(dbussy_adapter.CachingBus, self).__init__(*args,
787
775
                                                            **kwargs)
788
 
 
789
776
        def get_object(self, busname, objectpath):
790
777
            try:
791
778
                return self.object_cache[(busname, objectpath)]
793
780
                new_object = super(
794
781
                    dbussy_adapter.CachingBus,
795
782
                    self).get_object(busname, objectpath)
796
 
                self.object_cache[(busname, objectpath)] = new_object
 
783
                self.object_cache[(busname, objectpath)]  = new_object
797
784
                return new_object
798
785
 
799
786
 
835
822
 
836
823
    class Base:
837
824
        """Abstract base class for commands"""
838
 
 
839
825
        def run(self, clients, bus=None):
840
826
            """Normal commands should implement run_on_one_client(),
841
827
but commands which want to operate on all clients at the same time can
845
831
            for client, properties in clients.items():
846
832
                self.run_on_one_client(client, properties)
847
833
 
 
834
 
848
835
    class IsEnabled(Base):
849
836
        def run(self, clients, bus=None):
850
837
            properties = next(iter(clients.values()))
852
839
                sys.exit(0)
853
840
            sys.exit(1)
854
841
 
 
842
 
855
843
    class Approve(Base):
856
844
        def run_on_one_client(self, client, properties):
857
845
            self.bus.call_client_method(client, "Approve", True)
858
846
 
 
847
 
859
848
    class Deny(Base):
860
849
        def run_on_one_client(self, client, properties):
861
850
            self.bus.call_client_method(client, "Approve", False)
862
851
 
 
852
 
863
853
    class Remove(Base):
864
854
        def run(self, clients, bus):
865
855
            for clientpath in frozenset(clients.keys()):
866
856
                bus.call_server_method("RemoveClient", clientpath)
867
857
 
 
858
 
868
859
    class Output(Base):
869
860
        """Abstract class for commands outputting client details"""
870
861
        all_keywords = ("Name", "Enabled", "Timeout", "LastCheckedOK",
876
867
                        "Checker", "ExtendedTimeout", "Expires",
877
868
                        "LastCheckerStatus")
878
869
 
 
870
 
879
871
    class DumpJSON(Output):
880
872
        def run(self, clients, bus=None):
881
873
            data = {properties["Name"]:
882
874
                    {key: properties[key]
883
875
                     for key in self.all_keywords}
884
876
                    for properties in clients.values()}
885
 
            print(json.dumps(data, indent=4, separators=(",", ": ")))
 
877
            print(json.dumps(data, indent=4, separators=(',', ': ')))
 
878
 
886
879
 
887
880
    class PrintTable(Output):
888
881
        def __init__(self, verbose=False):
929
922
 
930
923
            if sys.version_info.major == 2:
931
924
                __unicode__ = __str__
932
 
 
933
925
                def __str__(self):
934
926
                    return str(self).encode(
935
927
                        locale.getpreferredencoding())
981
973
                                minutes=(td.seconds % 3600) // 60,
982
974
                                seconds=td.seconds % 60))
983
975
 
 
976
 
984
977
    class PropertySetter(Base):
985
978
        "Abstract class for Actions for setting one client property"
986
979
 
993
986
        def propname(self):
994
987
            raise NotImplementedError()
995
988
 
 
989
 
996
990
    class Enable(PropertySetter):
997
991
        propname = "Enabled"
998
992
        value_to_set = True
999
993
 
 
994
 
1000
995
    class Disable(PropertySetter):
1001
996
        propname = "Enabled"
1002
997
        value_to_set = False
1003
998
 
 
999
 
1004
1000
    class BumpTimeout(PropertySetter):
1005
1001
        propname = "LastCheckedOK"
1006
1002
        value_to_set = ""
1007
1003
 
 
1004
 
1008
1005
    class StartChecker(PropertySetter):
1009
1006
        propname = "CheckerRunning"
1010
1007
        value_to_set = True
1011
1008
 
 
1009
 
1012
1010
    class StopChecker(PropertySetter):
1013
1011
        propname = "CheckerRunning"
1014
1012
        value_to_set = False
1015
1013
 
 
1014
 
1016
1015
    class ApproveByDefault(PropertySetter):
1017
1016
        propname = "ApprovedByDefault"
1018
1017
        value_to_set = True
1019
1018
 
 
1019
 
1020
1020
    class DenyByDefault(PropertySetter):
1021
1021
        propname = "ApprovedByDefault"
1022
1022
        value_to_set = False
1023
1023
 
 
1024
 
1024
1025
    class PropertySetterValue(PropertySetter):
1025
1026
        """Abstract class for PropertySetter recieving a value as
1026
1027
constructor argument instead of a class attribute."""
1027
 
 
1028
1028
        def __init__(self, value):
1029
1029
            self.value_to_set = value
1030
1030
 
1037
1037
    class SetChecker(PropertySetterValue):
1038
1038
        propname = "Checker"
1039
1039
 
 
1040
 
1040
1041
    class SetHost(PropertySetterValue):
1041
1042
        propname = "Host"
1042
1043
 
 
1044
 
1043
1045
    class SetSecret(PropertySetterValue):
1044
1046
        propname = "Secret"
1045
1047
 
1053
1055
            self._vts = value.read()
1054
1056
            value.close()
1055
1057
 
 
1058
 
1056
1059
    class PropertySetterValueMilliseconds(PropertySetterValue):
1057
1060
        """Abstract class for PropertySetterValue taking a value
1058
1061
argument as a datetime.timedelta() but should store it as
1067
1070
            "When setting, convert value from a datetime.timedelta"
1068
1071
            self._vts = int(round(value.total_seconds() * 1000))
1069
1072
 
 
1073
 
1070
1074
    class SetTimeout(PropertySetterValueMilliseconds):
1071
1075
        propname = "Timeout"
1072
1076
 
 
1077
 
1073
1078
    class SetExtendedTimeout(PropertySetterValueMilliseconds):
1074
1079
        propname = "ExtendedTimeout"
1075
1080
 
 
1081
 
1076
1082
    class SetInterval(PropertySetterValueMilliseconds):
1077
1083
        propname = "Interval"
1078
1084
 
 
1085
 
1079
1086
    class SetApprovalDelay(PropertySetterValueMilliseconds):
1080
1087
        propname = "ApprovalDelay"
1081
1088
 
 
1089
 
1082
1090
    class SetApprovalDuration(PropertySetterValueMilliseconds):
1083
1091
        propname = "ApprovalDuration"
1084
1092
 
1755
1763
        self.assertIs(ret, expected_method_return)
1756
1764
 
1757
1765
    def test_call_method_handles_exception(self):
 
1766
        dbus_logger = logging.getLogger("dbus.proxies")
 
1767
 
1758
1768
        def func():
1759
1769
            raise gi.repository.GLib.Error()
1760
1770
 
2427
2437
        busname = "se.recompile.Mandos"
2428
2438
        client_interface = "se.recompile.Mandos.Client"
2429
2439
        command.Approve().run(self.bus.clients, self.bus)
2430
 
        self.assertTrue(self.bus.clients)
2431
2440
        for clientpath in self.bus.clients:
2432
2441
            self.assertIn(("Approve", busname, clientpath,
2433
2442
                           client_interface, (True,)), self.bus.calls)
2436
2445
        busname = "se.recompile.Mandos"
2437
2446
        client_interface = "se.recompile.Mandos.Client"
2438
2447
        command.Deny().run(self.bus.clients, self.bus)
2439
 
        self.assertTrue(self.bus.clients)
2440
2448
        for clientpath in self.bus.clients:
2441
2449
            self.assertIn(("Approve", busname, clientpath,
2442
2450
                           client_interface, (False,)),
2443
2451
                          self.bus.calls)
2444
2452
 
2445
2453
    def test_Remove(self):
2446
 
        busname = "se.recompile.Mandos"
2447
 
        server_path = "/"
2448
 
        server_interface = "se.recompile.Mandos"
2449
 
        orig_clients = self.bus.clients.copy()
2450
2454
        command.Remove().run(self.bus.clients, self.bus)
2451
 
        self.assertFalse(self.bus.clients)
2452
 
        for clientpath in orig_clients:
2453
 
            self.assertIn(("RemoveClient", busname,
2454
 
                           server_path, server_interface,
 
2455
        for clientpath in self.bus.clients:
 
2456
            self.assertIn(("RemoveClient", dbus_busname,
 
2457
                           dbus_server_path, dbus_server_interface,
2455
2458
                           (clientpath,)), self.bus.calls)
2456
2459
 
2457
2460
    expected_json = {
2659
2662
        else:
2660
2663
            cmd_args = [() for x in range(len(self.values_to_get))]
2661
2664
            values_to_get = self.values_to_get
2662
 
        self.assertTrue(values_to_get)
2663
2665
        for value_to_get, cmd_arg in zip(values_to_get, cmd_args):
2664
2666
            for clientpath in self.bus.clients:
2665
2667
                self.bus.clients[clientpath][self.propname] = (
2666
2668
                    Unique())
2667
2669
            self.command(*cmd_arg).run(self.bus.clients, self.bus)
2668
 
            self.assertTrue(self.bus.clients)
2669
2670
            for clientpath in self.bus.clients:
2670
2671
                value = (self.bus.clients[clientpath]
2671
2672
                         [self.propname])
2730
2731
class TestSetSecretCmd(TestPropertySetterCmd):
2731
2732
    command = command.SetSecret
2732
2733
    propname = "Secret"
2733
 
    def __init__(self, *args, **kwargs):
2734
 
        self.values_to_set = [io.BytesIO(b""),
2735
 
                              io.BytesIO(b"secret\0xyzzy\nbar")]
2736
 
        self.values_to_get = [f.getvalue() for f in
2737
 
                              self.values_to_set]
2738
 
        super(TestSetSecretCmd, self).__init__(*args, **kwargs)
 
2734
    values_to_set = [io.BytesIO(b""),
 
2735
                     io.BytesIO(b"secret\0xyzzy\nbar")]
 
2736
    values_to_get = [f.getvalue() for f in values_to_set]
2739
2737
 
2740
2738
 
2741
2739
class TestSetTimeoutCmd(TestPropertySetterCmd):
2794
2792
 
2795
2793
 
2796
2794
 
2797
 
def parse_test_args():
2798
 
    # type: () -> argparse.Namespace
 
2795
def should_only_run_tests():
2799
2796
    parser = argparse.ArgumentParser(add_help=False)
2800
 
    parser.add_argument("--check", action="store_true")
2801
 
    parser.add_argument("--prefix", )
 
2797
    parser.add_argument("--check", action='store_true')
2802
2798
    args, unknown_args = parser.parse_known_args()
2803
 
    if args.check:
2804
 
        # Remove test options from sys.argv
 
2799
    run_tests = args.check
 
2800
    if run_tests:
 
2801
        # Remove --check argument from sys.argv
2805
2802
        sys.argv[1:] = unknown_args
2806
 
    return args
 
2803
    return run_tests
2807
2804
 
2808
2805
# Add all tests from doctest strings
2809
2806
def load_tests(loader, tests, none):
2812
2809
    return tests
2813
2810
 
2814
2811
if __name__ == "__main__":
2815
 
    options = parse_test_args()
2816
2812
    try:
2817
 
        if options.check:
2818
 
            extra_test_prefix = options.prefix
2819
 
            if extra_test_prefix is not None:
2820
 
                if not (unittest.main(argv=[""], exit=False)
2821
 
                        .result.wasSuccessful()):
2822
 
                    sys.exit(1)
2823
 
                class ExtraTestLoader(unittest.TestLoader):
2824
 
                    testMethodPrefix = extra_test_prefix
2825
 
                # Call using ./scriptname --check [--verbose]
2826
 
                unittest.main(argv=[""], testLoader=ExtraTestLoader())
2827
 
            else:
2828
 
                unittest.main(argv=[""])
 
2813
        if should_only_run_tests():
 
2814
            # Call using ./tdd-python-script --check [--verbose]
 
2815
            unittest.main()
2829
2816
        else:
2830
2817
            main()
2831
2818
    finally:
2832
2819
        logging.shutdown()
2833
 
 
2834
 
# Local Variables:
2835
 
# run-tests:
2836
 
# (lambda (&optional extra)
2837
 
#   (if (not (funcall run-tests-in-test-buffer default-directory
2838
 
#             extra))
2839
 
#       (funcall show-test-buffer-in-test-window)
2840
 
#     (funcall remove-test-window)
2841
 
#     (if extra (message "Extra tests run successfully!"))))
2842
 
# run-tests-in-test-buffer:
2843
 
# (lambda (dir &optional extra)
2844
 
#   (with-current-buffer (get-buffer-create "*Test*")
2845
 
#     (setq buffer-read-only nil
2846
 
#           default-directory dir)
2847
 
#     (erase-buffer)
2848
 
#     (compilation-mode))
2849
 
#   (let ((process-result
2850
 
#          (let ((inhibit-read-only t))
2851
 
#            (process-file-shell-command
2852
 
#             (funcall get-command-line extra) nil "*Test*"))))
2853
 
#     (and (numberp process-result)
2854
 
#          (= process-result 0))))
2855
 
# get-command-line:
2856
 
# (lambda (&optional extra)
2857
 
#   (let ((quoted-script
2858
 
#          (shell-quote-argument (funcall get-script-name))))
2859
 
#     (format
2860
 
#      (concat "%s --check" (if extra " --prefix=atest" ""))
2861
 
#      quoted-script)))
2862
 
# get-script-name:
2863
 
# (lambda ()
2864
 
#   (if (fboundp 'file-local-name)
2865
 
#       (file-local-name (buffer-file-name))
2866
 
#     (or (file-remote-p (buffer-file-name) 'localname)
2867
 
#         (buffer-file-name))))
2868
 
# remove-test-window:
2869
 
# (lambda ()
2870
 
#   (let ((test-window (get-buffer-window "*Test*")))
2871
 
#     (if test-window (delete-window test-window))))
2872
 
# show-test-buffer-in-test-window:
2873
 
# (lambda ()
2874
 
#   (when (not (get-buffer-window-list "*Test*"))
2875
 
#     (setq next-error-last-buffer (get-buffer "*Test*"))
2876
 
#     (let* ((side (if (>= (window-width) 146) 'right 'bottom))
2877
 
#            (display-buffer-overriding-action
2878
 
#             `((display-buffer-in-side-window) (side . ,side)
2879
 
#               (window-height . fit-window-to-buffer)
2880
 
#               (window-width . fit-window-to-buffer))))
2881
 
#       (display-buffer "*Test*"))))
2882
 
# eval:
2883
 
# (progn
2884
 
#   (let* ((run-extra-tests (lambda () (interactive)
2885
 
#                             (funcall run-tests t)))
2886
 
#          (inner-keymap `(keymap (116 . ,run-extra-tests))) ; t
2887
 
#          (outer-keymap `(keymap (3 . ,inner-keymap))))     ; C-c
2888
 
#     (setq minor-mode-overriding-map-alist
2889
 
#           (cons `(run-tests . ,outer-keymap)
2890
 
#                 minor-mode-overriding-map-alist)))
2891
 
#   (add-hook 'after-save-hook run-tests 90 t))
2892
 
# End: