/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-11-30 16:19:48 UTC
  • mto: This revision was merged to the branch mainline in revision 402.
  • Revision ID: teddy@recompile.se-20201130161948-jozzftghviuvk7kb
Fix debconf template syntax
  
* debian/mandos-client.templates: Rename "_description" header to
  "_Description".

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 -*-
 
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
3
#
4
4
# Mandos Control - Control or query the Mandos server
5
5
#
6
 
# Copyright © 2008-2022 Teddy Hogeborn
7
 
# Copyright © 2008-2022 Björn Påhlsson
 
6
# Copyright © 2008-2020 Teddy Hogeborn
 
7
# Copyright © 2008-2020 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.17"
 
92
version = "1.8.12"
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
729
            # list of one element with the return value
739
730
            if value:
780
771
 
781
772
    class CachingBus(MandosBus):
782
773
        """A caching layer for dbussy_adapter.MandosBus"""
783
 
 
784
774
        def __init__(self, *args, **kwargs):
785
775
            self.object_cache = {}
786
776
            super(dbussy_adapter.CachingBus, self).__init__(*args,
787
777
                                                            **kwargs)
788
 
 
789
778
        def get_object(self, busname, objectpath):
790
779
            try:
791
780
                return self.object_cache[(busname, objectpath)]
793
782
                new_object = super(
794
783
                    dbussy_adapter.CachingBus,
795
784
                    self).get_object(busname, objectpath)
796
 
                self.object_cache[(busname, objectpath)] = new_object
 
785
                self.object_cache[(busname, objectpath)]  = new_object
797
786
                return new_object
798
787
 
799
788
 
835
824
 
836
825
    class Base:
837
826
        """Abstract base class for commands"""
838
 
 
839
827
        def run(self, clients, bus=None):
840
828
            """Normal commands should implement run_on_one_client(),
841
829
but commands which want to operate on all clients at the same time can
845
833
            for client, properties in clients.items():
846
834
                self.run_on_one_client(client, properties)
847
835
 
 
836
 
848
837
    class IsEnabled(Base):
849
838
        def run(self, clients, bus=None):
850
839
            properties = next(iter(clients.values()))
852
841
                sys.exit(0)
853
842
            sys.exit(1)
854
843
 
 
844
 
855
845
    class Approve(Base):
856
846
        def run_on_one_client(self, client, properties):
857
847
            self.bus.call_client_method(client, "Approve", True)
858
848
 
 
849
 
859
850
    class Deny(Base):
860
851
        def run_on_one_client(self, client, properties):
861
852
            self.bus.call_client_method(client, "Approve", False)
862
853
 
 
854
 
863
855
    class Remove(Base):
864
856
        def run(self, clients, bus):
865
857
            for clientpath in frozenset(clients.keys()):
866
858
                bus.call_server_method("RemoveClient", clientpath)
867
859
 
 
860
 
868
861
    class Output(Base):
869
862
        """Abstract class for commands outputting client details"""
870
863
        all_keywords = ("Name", "Enabled", "Timeout", "LastCheckedOK",
876
869
                        "Checker", "ExtendedTimeout", "Expires",
877
870
                        "LastCheckerStatus")
878
871
 
 
872
 
879
873
    class DumpJSON(Output):
880
874
        def run(self, clients, bus=None):
881
875
            data = {properties["Name"]:
882
876
                    {key: properties[key]
883
877
                     for key in self.all_keywords}
884
878
                    for properties in clients.values()}
885
 
            print(json.dumps(data, indent=4, separators=(",", ": ")))
 
879
            print(json.dumps(data, indent=4, separators=(',', ': ')))
 
880
 
886
881
 
887
882
    class PrintTable(Output):
888
883
        def __init__(self, verbose=False):
929
924
 
930
925
            if sys.version_info.major == 2:
931
926
                __unicode__ = __str__
932
 
 
933
927
                def __str__(self):
934
928
                    return str(self).encode(
935
929
                        locale.getpreferredencoding())
981
975
                                minutes=(td.seconds % 3600) // 60,
982
976
                                seconds=td.seconds % 60))
983
977
 
 
978
 
984
979
    class PropertySetter(Base):
985
980
        "Abstract class for Actions for setting one client property"
986
981
 
993
988
        def propname(self):
994
989
            raise NotImplementedError()
995
990
 
 
991
 
996
992
    class Enable(PropertySetter):
997
993
        propname = "Enabled"
998
994
        value_to_set = True
999
995
 
 
996
 
1000
997
    class Disable(PropertySetter):
1001
998
        propname = "Enabled"
1002
999
        value_to_set = False
1003
1000
 
 
1001
 
1004
1002
    class BumpTimeout(PropertySetter):
1005
1003
        propname = "LastCheckedOK"
1006
1004
        value_to_set = ""
1007
1005
 
 
1006
 
1008
1007
    class StartChecker(PropertySetter):
1009
1008
        propname = "CheckerRunning"
1010
1009
        value_to_set = True
1011
1010
 
 
1011
 
1012
1012
    class StopChecker(PropertySetter):
1013
1013
        propname = "CheckerRunning"
1014
1014
        value_to_set = False
1015
1015
 
 
1016
 
1016
1017
    class ApproveByDefault(PropertySetter):
1017
1018
        propname = "ApprovedByDefault"
1018
1019
        value_to_set = True
1019
1020
 
 
1021
 
1020
1022
    class DenyByDefault(PropertySetter):
1021
1023
        propname = "ApprovedByDefault"
1022
1024
        value_to_set = False
1023
1025
 
 
1026
 
1024
1027
    class PropertySetterValue(PropertySetter):
1025
1028
        """Abstract class for PropertySetter recieving a value as
1026
1029
constructor argument instead of a class attribute."""
1027
 
 
1028
1030
        def __init__(self, value):
1029
1031
            self.value_to_set = value
1030
1032
 
1037
1039
    class SetChecker(PropertySetterValue):
1038
1040
        propname = "Checker"
1039
1041
 
 
1042
 
1040
1043
    class SetHost(PropertySetterValue):
1041
1044
        propname = "Host"
1042
1045
 
 
1046
 
1043
1047
    class SetSecret(PropertySetterValue):
1044
1048
        propname = "Secret"
1045
1049
 
1053
1057
            self._vts = value.read()
1054
1058
            value.close()
1055
1059
 
 
1060
 
1056
1061
    class PropertySetterValueMilliseconds(PropertySetterValue):
1057
1062
        """Abstract class for PropertySetterValue taking a value
1058
1063
argument as a datetime.timedelta() but should store it as
1067
1072
            "When setting, convert value from a datetime.timedelta"
1068
1073
            self._vts = int(round(value.total_seconds() * 1000))
1069
1074
 
 
1075
 
1070
1076
    class SetTimeout(PropertySetterValueMilliseconds):
1071
1077
        propname = "Timeout"
1072
1078
 
 
1079
 
1073
1080
    class SetExtendedTimeout(PropertySetterValueMilliseconds):
1074
1081
        propname = "ExtendedTimeout"
1075
1082
 
 
1083
 
1076
1084
    class SetInterval(PropertySetterValueMilliseconds):
1077
1085
        propname = "Interval"
1078
1086
 
 
1087
 
1079
1088
    class SetApprovalDelay(PropertySetterValueMilliseconds):
1080
1089
        propname = "ApprovalDelay"
1081
1090
 
 
1091
 
1082
1092
    class SetApprovalDuration(PropertySetterValueMilliseconds):
1083
1093
        propname = "ApprovalDuration"
1084
1094
 
1755
1765
        self.assertIs(ret, expected_method_return)
1756
1766
 
1757
1767
    def test_call_method_handles_exception(self):
 
1768
        dbus_logger = logging.getLogger("dbus.proxies")
 
1769
 
1758
1770
        def func():
1759
1771
            raise gi.repository.GLib.Error()
1760
1772
 
2427
2439
        busname = "se.recompile.Mandos"
2428
2440
        client_interface = "se.recompile.Mandos.Client"
2429
2441
        command.Approve().run(self.bus.clients, self.bus)
2430
 
        self.assertTrue(self.bus.clients)
2431
2442
        for clientpath in self.bus.clients:
2432
2443
            self.assertIn(("Approve", busname, clientpath,
2433
2444
                           client_interface, (True,)), self.bus.calls)
2436
2447
        busname = "se.recompile.Mandos"
2437
2448
        client_interface = "se.recompile.Mandos.Client"
2438
2449
        command.Deny().run(self.bus.clients, self.bus)
2439
 
        self.assertTrue(self.bus.clients)
2440
2450
        for clientpath in self.bus.clients:
2441
2451
            self.assertIn(("Approve", busname, clientpath,
2442
2452
                           client_interface, (False,)),
2443
2453
                          self.bus.calls)
2444
2454
 
2445
2455
    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
2456
        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,
 
2457
        for clientpath in self.bus.clients:
 
2458
            self.assertIn(("RemoveClient", dbus_busname,
 
2459
                           dbus_server_path, dbus_server_interface,
2455
2460
                           (clientpath,)), self.bus.calls)
2456
2461
 
2457
2462
    expected_json = {
2659
2664
        else:
2660
2665
            cmd_args = [() for x in range(len(self.values_to_get))]
2661
2666
            values_to_get = self.values_to_get
2662
 
        self.assertTrue(values_to_get)
2663
2667
        for value_to_get, cmd_arg in zip(values_to_get, cmd_args):
2664
2668
            for clientpath in self.bus.clients:
2665
2669
                self.bus.clients[clientpath][self.propname] = (
2666
2670
                    Unique())
2667
2671
            self.command(*cmd_arg).run(self.bus.clients, self.bus)
2668
 
            self.assertTrue(self.bus.clients)
2669
2672
            for clientpath in self.bus.clients:
2670
2673
                value = (self.bus.clients[clientpath]
2671
2674
                         [self.propname])
2730
2733
class TestSetSecretCmd(TestPropertySetterCmd):
2731
2734
    command = command.SetSecret
2732
2735
    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)
 
2736
    values_to_set = [io.BytesIO(b""),
 
2737
                     io.BytesIO(b"secret\0xyzzy\nbar")]
 
2738
    values_to_get = [f.getvalue() for f in values_to_set]
2739
2739
 
2740
2740
 
2741
2741
class TestSetTimeoutCmd(TestPropertySetterCmd):
2794
2794
 
2795
2795
 
2796
2796
 
2797
 
def parse_test_args():
2798
 
    # type: () -> argparse.Namespace
 
2797
def should_only_run_tests():
2799
2798
    parser = argparse.ArgumentParser(add_help=False)
2800
 
    parser.add_argument("--check", action="store_true")
2801
 
    parser.add_argument("--prefix", )
 
2799
    parser.add_argument("--check", action='store_true')
2802
2800
    args, unknown_args = parser.parse_known_args()
2803
 
    if args.check:
2804
 
        # Remove test options from sys.argv
 
2801
    run_tests = args.check
 
2802
    if run_tests:
 
2803
        # Remove --check argument from sys.argv
2805
2804
        sys.argv[1:] = unknown_args
2806
 
    return args
 
2805
    return run_tests
2807
2806
 
2808
2807
# Add all tests from doctest strings
2809
2808
def load_tests(loader, tests, none):
2812
2811
    return tests
2813
2812
 
2814
2813
if __name__ == "__main__":
2815
 
    options = parse_test_args()
2816
2814
    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=[""])
 
2815
        if should_only_run_tests():
 
2816
            # Call using ./tdd-python-script --check [--verbose]
 
2817
            unittest.main()
2829
2818
        else:
2830
2819
            main()
2831
2820
    finally:
2832
2821
        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: