/mandos/trunk

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

« back to all changes in this revision

Viewing changes to mandos-ctl

  • Committer: Teddy Hogeborn
  • Date: 2024-09-09 03:08:13 UTC
  • Revision ID: teddy@recompile.se-20240909030813-hrv5jg2mlae92dq8
Update Debian Policy version to 4.7.0; no other changes necessary.

* debian/control (Standards-Version): Change to "4.7.0".

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
1
#!/usr/bin/python3 -bbI
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
 
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
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
 
 
27
26
from __future__ import (division, absolute_import, print_function,
28
27
                        unicode_literals)
29
28
 
33
32
    pass
34
33
 
35
34
import sys
 
35
import unittest
36
36
import argparse
 
37
import logging
 
38
import os
37
39
import locale
38
40
import datetime
39
41
import re
40
 
import os
41
42
import collections
42
43
import json
43
 
import unittest
44
 
import logging
45
44
import io
46
45
import tempfile
47
46
import contextlib
49
48
if sys.version_info.major == 2:
50
49
    __metaclass__ = type
51
50
    str = unicode
 
51
    input = raw_input
 
52
 
52
53
 
53
54
class gi:
54
55
    """Dummy gi module, for the tests"""
56
57
        class GLib:
57
58
            class Error(Exception):
58
59
                pass
 
60
 
 
61
 
59
62
dbussy = None
60
63
ravel = None
61
64
dbus_python = None
77
80
    import warnings
78
81
    warnings.simplefilter("default")
79
82
 
80
 
log = logging.getLogger(sys.argv[0])
81
 
logging.basicConfig(level="INFO", # Show info level messages
 
83
log = logging.getLogger(os.path.basename(sys.argv[0]))
 
84
logging.basicConfig(level="INFO",         # Show info level messages
82
85
                    format="%(message)s") # Show basic log messages
83
86
 
84
87
logging.captureWarnings(True)   # Show warnings via the logging system
89
92
 
90
93
locale.setlocale(locale.LC_ALL, "")
91
94
 
92
 
version = "1.8.9"
 
95
version = "1.8.16"
93
96
 
94
97
 
95
98
def main():
102
105
    clientnames = options.client
103
106
 
104
107
    if options.debug:
105
 
        log.setLevel(logging.DEBUG)
 
108
        logging.getLogger("").setLevel(logging.DEBUG)
106
109
 
107
110
    if dbussy is not None and ravel is not None:
108
111
        bus = dbussy_adapter.CachingBus(dbussy, ravel)
256
259
        return rfc3339_duration_to_delta(interval)
257
260
    except ValueError as e:
258
261
        log.warning("%s - Parsing as pre-1.6.1 interval instead",
259
 
                    ' '.join(e.args))
 
262
                    " ".join(e.args))
260
263
    return parse_pre_1_6_1_interval(interval)
261
264
 
262
265
 
392
395
 
393
396
 
394
397
def parse_pre_1_6_1_interval(interval):
395
 
    """Parse an interval string as documented by Mandos before 1.6.1,
 
398
    r"""Parse an interval string as documented by Mandos before 1.6.1,
396
399
    and return a datetime.timedelta
397
400
 
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)
 
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)
411
414
    True
412
415
    >>> # Ignore unknown characters, allow any order and repetitions
413
 
    >>> parse_pre_1_6_1_interval('2dxy7zz11y3m5m') == datetime.timedelta(2, 480, 18000)
 
416
    >>> parse_pre_1_6_1_interval("2dxy7zz11y3m5m") \
 
417
    ... == datetime.timedelta(2, 480, 18000)
414
418
    True
415
419
 
416
420
    """
485
489
    class SystemBus:
486
490
 
487
491
        object_manager_iface = "org.freedesktop.DBus.ObjectManager"
 
492
 
488
493
        def get_managed_objects(self, busname, objectpath):
489
494
            return self.call_method("GetManagedObjects", busname,
490
495
                                    objectpath,
491
496
                                    self.object_manager_iface)
492
497
 
493
498
        properties_iface = "org.freedesktop.DBus.Properties"
 
499
 
494
500
        def set_property(self, busname, objectpath, interface, key,
495
501
                         value):
496
502
            self.call_method("Set", busname, objectpath,
501
507
                        interface, *args):
502
508
            raise NotImplementedError()
503
509
 
504
 
 
505
510
    class MandosBus(SystemBus):
506
511
        busname_domain = "se.recompile"
507
512
        busname = busname_domain + ".Mandos"
600
605
 
601
606
    class SilenceLogger:
602
607
        "Simple context manager to silence a particular logger"
 
608
 
603
609
        def __init__(self, loggername):
604
610
            self.logger = logging.getLogger(loggername)
605
611
 
615
621
        def __exit__(self, exc_type, exc_val, exc_tb):
616
622
            self.logger.removeFilter(self.nullfilter)
617
623
 
618
 
 
619
624
    class CachingBus(SystemBus):
620
625
        """A caching layer for dbus_python_adapter.SystemBus"""
 
626
 
621
627
        def __init__(self, *args, **kwargs):
622
628
            self.object_cache = {}
623
629
            super(dbus_python_adapter.CachingBus,
624
630
                  self).__init__(*args, **kwargs)
 
631
 
625
632
        def get_object(self, busname, objectpath):
626
633
            try:
627
634
                return self.object_cache[(busname, objectpath)]
629
636
                new_object = super(
630
637
                    dbus_python_adapter.CachingBus,
631
638
                    self).get_object(busname, objectpath)
632
 
                self.object_cache[(busname, objectpath)]  = new_object
 
639
                self.object_cache[(busname, objectpath)] = new_object
633
640
                return new_object
634
641
 
635
642
 
682
689
 
683
690
    class CachingBus(SystemBus):
684
691
        """A caching layer for pydbus_adapter.SystemBus"""
 
692
 
685
693
        def __init__(self, *args, **kwargs):
686
694
            self.object_cache = {}
687
695
            super(pydbus_adapter.CachingBus,
688
696
                  self).__init__(*args, **kwargs)
 
697
 
689
698
        def get(self, busname, objectpath):
690
699
            try:
691
700
                return self.object_cache[(busname, objectpath)]
692
701
            except KeyError:
693
702
                new_object = (super(pydbus_adapter.CachingBus, self)
694
703
                              .get(busname, objectpath))
695
 
                self.object_cache[(busname, objectpath)]  = new_object
 
704
                self.object_cache[(busname, objectpath)] = new_object
696
705
                return new_object
697
706
 
698
707
 
724
733
            iface = proxy_object.get_interface(interface)
725
734
            method = getattr(iface, methodname)
726
735
            with self.convert_exception(dbus.Error):
727
 
                value =  method(*args)
 
736
                value = method(*args)
728
737
            # DBussy returns values either as an empty list or as a
729
 
            # tuple: (signature, value)
 
738
            # list of one element with the return value
730
739
            if value:
731
740
                return self.type_filter(value[0])
732
741
 
738
747
 
739
748
        def type_filter(self, value):
740
749
            """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]
741
752
            if isinstance(value, tuple):
742
753
                if (len(value) == 2
743
754
                    and isinstance(value[0],
769
780
 
770
781
    class CachingBus(MandosBus):
771
782
        """A caching layer for dbussy_adapter.MandosBus"""
 
783
 
772
784
        def __init__(self, *args, **kwargs):
773
785
            self.object_cache = {}
774
786
            super(dbussy_adapter.CachingBus, self).__init__(*args,
775
787
                                                            **kwargs)
 
788
 
776
789
        def get_object(self, busname, objectpath):
777
790
            try:
778
791
                return self.object_cache[(busname, objectpath)]
780
793
                new_object = super(
781
794
                    dbussy_adapter.CachingBus,
782
795
                    self).get_object(busname, objectpath)
783
 
                self.object_cache[(busname, objectpath)]  = new_object
 
796
                self.object_cache[(busname, objectpath)] = new_object
784
797
                return new_object
785
798
 
786
799
 
822
835
 
823
836
    class Base:
824
837
        """Abstract base class for commands"""
 
838
 
825
839
        def run(self, clients, bus=None):
826
840
            """Normal commands should implement run_on_one_client(),
827
841
but commands which want to operate on all clients at the same time can
831
845
            for client, properties in clients.items():
832
846
                self.run_on_one_client(client, properties)
833
847
 
834
 
 
835
848
    class IsEnabled(Base):
836
849
        def run(self, clients, bus=None):
837
850
            properties = next(iter(clients.values()))
839
852
                sys.exit(0)
840
853
            sys.exit(1)
841
854
 
842
 
 
843
855
    class Approve(Base):
844
856
        def run_on_one_client(self, client, properties):
845
857
            self.bus.call_client_method(client, "Approve", True)
846
858
 
847
 
 
848
859
    class Deny(Base):
849
860
        def run_on_one_client(self, client, properties):
850
861
            self.bus.call_client_method(client, "Approve", False)
851
862
 
852
 
 
853
863
    class Remove(Base):
854
864
        def run(self, clients, bus):
855
865
            for clientpath in frozenset(clients.keys()):
856
866
                bus.call_server_method("RemoveClient", clientpath)
857
867
 
858
 
 
859
868
    class Output(Base):
860
869
        """Abstract class for commands outputting client details"""
861
870
        all_keywords = ("Name", "Enabled", "Timeout", "LastCheckedOK",
867
876
                        "Checker", "ExtendedTimeout", "Expires",
868
877
                        "LastCheckerStatus")
869
878
 
870
 
 
871
879
    class DumpJSON(Output):
872
880
        def run(self, clients, bus=None):
873
881
            data = {properties["Name"]:
874
882
                    {key: properties[key]
875
883
                     for key in self.all_keywords}
876
884
                    for properties in clients.values()}
877
 
            print(json.dumps(data, indent=4, separators=(',', ': ')))
878
 
 
 
885
            print(json.dumps(data, indent=4, separators=(",", ": ")))
879
886
 
880
887
    class PrintTable(Output):
881
888
        def __init__(self, verbose=False):
922
929
 
923
930
            if sys.version_info.major == 2:
924
931
                __unicode__ = __str__
 
932
 
925
933
                def __str__(self):
926
934
                    return str(self).encode(
927
935
                        locale.getpreferredencoding())
973
981
                                minutes=(td.seconds % 3600) // 60,
974
982
                                seconds=td.seconds % 60))
975
983
 
976
 
 
977
984
    class PropertySetter(Base):
978
985
        "Abstract class for Actions for setting one client property"
979
986
 
986
993
        def propname(self):
987
994
            raise NotImplementedError()
988
995
 
989
 
 
990
996
    class Enable(PropertySetter):
991
997
        propname = "Enabled"
992
998
        value_to_set = True
993
999
 
994
 
 
995
1000
    class Disable(PropertySetter):
996
1001
        propname = "Enabled"
997
1002
        value_to_set = False
998
1003
 
999
 
 
1000
1004
    class BumpTimeout(PropertySetter):
1001
1005
        propname = "LastCheckedOK"
1002
1006
        value_to_set = ""
1003
1007
 
1004
 
 
1005
1008
    class StartChecker(PropertySetter):
1006
1009
        propname = "CheckerRunning"
1007
1010
        value_to_set = True
1008
1011
 
1009
 
 
1010
1012
    class StopChecker(PropertySetter):
1011
1013
        propname = "CheckerRunning"
1012
1014
        value_to_set = False
1013
1015
 
1014
 
 
1015
1016
    class ApproveByDefault(PropertySetter):
1016
1017
        propname = "ApprovedByDefault"
1017
1018
        value_to_set = True
1018
1019
 
1019
 
 
1020
1020
    class DenyByDefault(PropertySetter):
1021
1021
        propname = "ApprovedByDefault"
1022
1022
        value_to_set = False
1023
1023
 
1024
 
 
1025
1024
    class PropertySetterValue(PropertySetter):
1026
1025
        """Abstract class for PropertySetter recieving a value as
1027
1026
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
 
 
1041
1040
    class SetHost(PropertySetterValue):
1042
1041
        propname = "Host"
1043
1042
 
1044
 
 
1045
1043
    class SetSecret(PropertySetterValue):
1046
1044
        propname = "Secret"
1047
1045
 
1055
1053
            self._vts = value.read()
1056
1054
            value.close()
1057
1055
 
1058
 
 
1059
1056
    class PropertySetterValueMilliseconds(PropertySetterValue):
1060
1057
        """Abstract class for PropertySetterValue taking a value
1061
1058
argument as a datetime.timedelta() but should store it as
1070
1067
            "When setting, convert value from a datetime.timedelta"
1071
1068
            self._vts = int(round(value.total_seconds() * 1000))
1072
1069
 
1073
 
 
1074
1070
    class SetTimeout(PropertySetterValueMilliseconds):
1075
1071
        propname = "Timeout"
1076
1072
 
1077
 
 
1078
1073
    class SetExtendedTimeout(PropertySetterValueMilliseconds):
1079
1074
        propname = "ExtendedTimeout"
1080
1075
 
1081
 
 
1082
1076
    class SetInterval(PropertySetterValueMilliseconds):
1083
1077
        propname = "Interval"
1084
1078
 
1085
 
 
1086
1079
    class SetApprovalDelay(PropertySetterValueMilliseconds):
1087
1080
        propname = "ApprovalDelay"
1088
1081
 
1089
 
 
1090
1082
    class SetApprovalDuration(PropertySetterValueMilliseconds):
1091
1083
        propname = "ApprovalDuration"
1092
1084
 
1763
1755
        self.assertIs(ret, expected_method_return)
1764
1756
 
1765
1757
    def test_call_method_handles_exception(self):
1766
 
        dbus_logger = logging.getLogger("dbus.proxies")
1767
 
 
1768
1758
        def func():
1769
1759
            raise gi.repository.GLib.Error()
1770
1760
 
2437
2427
        busname = "se.recompile.Mandos"
2438
2428
        client_interface = "se.recompile.Mandos.Client"
2439
2429
        command.Approve().run(self.bus.clients, self.bus)
 
2430
        self.assertTrue(self.bus.clients)
2440
2431
        for clientpath in self.bus.clients:
2441
2432
            self.assertIn(("Approve", busname, clientpath,
2442
2433
                           client_interface, (True,)), self.bus.calls)
2445
2436
        busname = "se.recompile.Mandos"
2446
2437
        client_interface = "se.recompile.Mandos.Client"
2447
2438
        command.Deny().run(self.bus.clients, self.bus)
 
2439
        self.assertTrue(self.bus.clients)
2448
2440
        for clientpath in self.bus.clients:
2449
2441
            self.assertIn(("Approve", busname, clientpath,
2450
2442
                           client_interface, (False,)),
2451
2443
                          self.bus.calls)
2452
2444
 
2453
2445
    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()
2454
2450
        command.Remove().run(self.bus.clients, self.bus)
2455
 
        for clientpath in self.bus.clients:
2456
 
            self.assertIn(("RemoveClient", dbus_busname,
2457
 
                           dbus_server_path, dbus_server_interface,
 
2451
        self.assertFalse(self.bus.clients)
 
2452
        for clientpath in orig_clients:
 
2453
            self.assertIn(("RemoveClient", busname,
 
2454
                           server_path, server_interface,
2458
2455
                           (clientpath,)), self.bus.calls)
2459
2456
 
2460
2457
    expected_json = {
2662
2659
        else:
2663
2660
            cmd_args = [() for x in range(len(self.values_to_get))]
2664
2661
            values_to_get = self.values_to_get
 
2662
        self.assertTrue(values_to_get)
2665
2663
        for value_to_get, cmd_arg in zip(values_to_get, cmd_args):
2666
2664
            for clientpath in self.bus.clients:
2667
2665
                self.bus.clients[clientpath][self.propname] = (
2668
2666
                    Unique())
2669
2667
            self.command(*cmd_arg).run(self.bus.clients, self.bus)
 
2668
            self.assertTrue(self.bus.clients)
2670
2669
            for clientpath in self.bus.clients:
2671
2670
                value = (self.bus.clients[clientpath]
2672
2671
                         [self.propname])
2731
2730
class TestSetSecretCmd(TestPropertySetterCmd):
2732
2731
    command = command.SetSecret
2733
2732
    propname = "Secret"
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]
 
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)
2737
2739
 
2738
2740
 
2739
2741
class TestSetTimeoutCmd(TestPropertySetterCmd):
2792
2794
 
2793
2795
 
2794
2796
 
2795
 
def should_only_run_tests():
 
2797
def parse_test_args():
 
2798
    # type: () -> argparse.Namespace
2796
2799
    parser = argparse.ArgumentParser(add_help=False)
2797
 
    parser.add_argument("--check", action='store_true')
 
2800
    parser.add_argument("--check", action="store_true")
 
2801
    parser.add_argument("--prefix", )
2798
2802
    args, unknown_args = parser.parse_known_args()
2799
 
    run_tests = args.check
2800
 
    if run_tests:
2801
 
        # Remove --check argument from sys.argv
 
2803
    if args.check:
 
2804
        # Remove test options from sys.argv
2802
2805
        sys.argv[1:] = unknown_args
2803
 
    return run_tests
 
2806
    return args
2804
2807
 
2805
2808
# Add all tests from doctest strings
2806
2809
def load_tests(loader, tests, none):
2809
2812
    return tests
2810
2813
 
2811
2814
if __name__ == "__main__":
 
2815
    options = parse_test_args()
2812
2816
    try:
2813
 
        if should_only_run_tests():
2814
 
            # Call using ./tdd-python-script --check [--verbose]
2815
 
            unittest.main()
 
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=[""])
2816
2829
        else:
2817
2830
            main()
2818
2831
    finally:
2819
2832
        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: