/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 Hogeborn
  • Date: 2024-09-08 00:11:24 UTC
  • mto: This revision was merged to the branch mainline in revision 410.
  • Revision ID: teddy@recompile.se-20240908001124-nq0ja17503ua9nmw
Minor documentation improvements 

* TODO (mandos-applet): Add link.
* debian/mandos-client.README.Debian: Fix spelling.

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,
497
503
                             self.properties_iface, interface, key,
498
504
                             value)
499
505
 
 
506
        def call_method(self, methodname, busname, objectpath,
 
507
                        interface, *args):
 
508
            raise NotImplementedError()
 
509
 
500
510
    class MandosBus(SystemBus):
501
511
        busname_domain = "se.recompile"
502
512
        busname = busname_domain + ".Mandos"
595
605
 
596
606
    class SilenceLogger:
597
607
        "Simple context manager to silence a particular logger"
 
608
 
598
609
        def __init__(self, loggername):
599
610
            self.logger = logging.getLogger(loggername)
600
611
 
610
621
        def __exit__(self, exc_type, exc_val, exc_tb):
611
622
            self.logger.removeFilter(self.nullfilter)
612
623
 
613
 
 
614
624
    class CachingBus(SystemBus):
615
625
        """A caching layer for dbus_python_adapter.SystemBus"""
 
626
 
616
627
        def __init__(self, *args, **kwargs):
617
628
            self.object_cache = {}
618
629
            super(dbus_python_adapter.CachingBus,
619
630
                  self).__init__(*args, **kwargs)
 
631
 
620
632
        def get_object(self, busname, objectpath):
621
633
            try:
622
634
                return self.object_cache[(busname, objectpath)]
624
636
                new_object = super(
625
637
                    dbus_python_adapter.CachingBus,
626
638
                    self).get_object(busname, objectpath)
627
 
                self.object_cache[(busname, objectpath)]  = new_object
 
639
                self.object_cache[(busname, objectpath)] = new_object
628
640
                return new_object
629
641
 
630
642
 
677
689
 
678
690
    class CachingBus(SystemBus):
679
691
        """A caching layer for pydbus_adapter.SystemBus"""
 
692
 
680
693
        def __init__(self, *args, **kwargs):
681
694
            self.object_cache = {}
682
695
            super(pydbus_adapter.CachingBus,
683
696
                  self).__init__(*args, **kwargs)
 
697
 
684
698
        def get(self, busname, objectpath):
685
699
            try:
686
700
                return self.object_cache[(busname, objectpath)]
687
701
            except KeyError:
688
702
                new_object = (super(pydbus_adapter.CachingBus, self)
689
703
                              .get(busname, objectpath))
690
 
                self.object_cache[(busname, objectpath)]  = new_object
 
704
                self.object_cache[(busname, objectpath)] = new_object
691
705
                return new_object
692
706
 
693
707
 
719
733
            iface = proxy_object.get_interface(interface)
720
734
            method = getattr(iface, methodname)
721
735
            with self.convert_exception(dbus.Error):
722
 
                value =  method(*args)
 
736
                value = method(*args)
723
737
            # DBussy returns values either as an empty list or as a
724
 
            # tuple: (signature, value)
 
738
            # list of one element with the return value
725
739
            if value:
726
740
                return self.type_filter(value[0])
727
741
 
733
747
 
734
748
        def type_filter(self, value):
735
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]
736
752
            if isinstance(value, tuple):
737
753
                if (len(value) == 2
738
754
                    and isinstance(value[0],
764
780
 
765
781
    class CachingBus(MandosBus):
766
782
        """A caching layer for dbussy_adapter.MandosBus"""
 
783
 
767
784
        def __init__(self, *args, **kwargs):
768
785
            self.object_cache = {}
769
786
            super(dbussy_adapter.CachingBus, self).__init__(*args,
770
787
                                                            **kwargs)
 
788
 
771
789
        def get_object(self, busname, objectpath):
772
790
            try:
773
791
                return self.object_cache[(busname, objectpath)]
775
793
                new_object = super(
776
794
                    dbussy_adapter.CachingBus,
777
795
                    self).get_object(busname, objectpath)
778
 
                self.object_cache[(busname, objectpath)]  = new_object
 
796
                self.object_cache[(busname, objectpath)] = new_object
779
797
                return new_object
780
798
 
781
799
 
817
835
 
818
836
    class Base:
819
837
        """Abstract base class for commands"""
 
838
 
820
839
        def run(self, clients, bus=None):
821
840
            """Normal commands should implement run_on_one_client(),
822
841
but commands which want to operate on all clients at the same time can
826
845
            for client, properties in clients.items():
827
846
                self.run_on_one_client(client, properties)
828
847
 
829
 
 
830
848
    class IsEnabled(Base):
831
849
        def run(self, clients, bus=None):
832
850
            properties = next(iter(clients.values()))
834
852
                sys.exit(0)
835
853
            sys.exit(1)
836
854
 
837
 
 
838
855
    class Approve(Base):
839
856
        def run_on_one_client(self, client, properties):
840
857
            self.bus.call_client_method(client, "Approve", True)
841
858
 
842
 
 
843
859
    class Deny(Base):
844
860
        def run_on_one_client(self, client, properties):
845
861
            self.bus.call_client_method(client, "Approve", False)
846
862
 
847
 
 
848
863
    class Remove(Base):
849
864
        def run(self, clients, bus):
850
865
            for clientpath in frozenset(clients.keys()):
851
866
                bus.call_server_method("RemoveClient", clientpath)
852
867
 
853
 
 
854
868
    class Output(Base):
855
869
        """Abstract class for commands outputting client details"""
856
870
        all_keywords = ("Name", "Enabled", "Timeout", "LastCheckedOK",
862
876
                        "Checker", "ExtendedTimeout", "Expires",
863
877
                        "LastCheckerStatus")
864
878
 
865
 
 
866
879
    class DumpJSON(Output):
867
880
        def run(self, clients, bus=None):
868
881
            data = {properties["Name"]:
869
882
                    {key: properties[key]
870
883
                     for key in self.all_keywords}
871
884
                    for properties in clients.values()}
872
 
            print(json.dumps(data, indent=4, separators=(',', ': ')))
873
 
 
 
885
            print(json.dumps(data, indent=4, separators=(",", ": ")))
874
886
 
875
887
    class PrintTable(Output):
876
888
        def __init__(self, verbose=False):
917
929
 
918
930
            if sys.version_info.major == 2:
919
931
                __unicode__ = __str__
 
932
 
920
933
                def __str__(self):
921
934
                    return str(self).encode(
922
935
                        locale.getpreferredencoding())
968
981
                                minutes=(td.seconds % 3600) // 60,
969
982
                                seconds=td.seconds % 60))
970
983
 
971
 
 
972
984
    class PropertySetter(Base):
973
985
        "Abstract class for Actions for setting one client property"
974
986
 
981
993
        def propname(self):
982
994
            raise NotImplementedError()
983
995
 
984
 
 
985
996
    class Enable(PropertySetter):
986
997
        propname = "Enabled"
987
998
        value_to_set = True
988
999
 
989
 
 
990
1000
    class Disable(PropertySetter):
991
1001
        propname = "Enabled"
992
1002
        value_to_set = False
993
1003
 
994
 
 
995
1004
    class BumpTimeout(PropertySetter):
996
1005
        propname = "LastCheckedOK"
997
1006
        value_to_set = ""
998
1007
 
999
 
 
1000
1008
    class StartChecker(PropertySetter):
1001
1009
        propname = "CheckerRunning"
1002
1010
        value_to_set = True
1003
1011
 
1004
 
 
1005
1012
    class StopChecker(PropertySetter):
1006
1013
        propname = "CheckerRunning"
1007
1014
        value_to_set = False
1008
1015
 
1009
 
 
1010
1016
    class ApproveByDefault(PropertySetter):
1011
1017
        propname = "ApprovedByDefault"
1012
1018
        value_to_set = True
1013
1019
 
1014
 
 
1015
1020
    class DenyByDefault(PropertySetter):
1016
1021
        propname = "ApprovedByDefault"
1017
1022
        value_to_set = False
1018
1023
 
1019
 
 
1020
1024
    class PropertySetterValue(PropertySetter):
1021
1025
        """Abstract class for PropertySetter recieving a value as
1022
1026
constructor argument instead of a class attribute."""
 
1027
 
1023
1028
        def __init__(self, value):
1024
1029
            self.value_to_set = value
1025
1030
 
1032
1037
    class SetChecker(PropertySetterValue):
1033
1038
        propname = "Checker"
1034
1039
 
1035
 
 
1036
1040
    class SetHost(PropertySetterValue):
1037
1041
        propname = "Host"
1038
1042
 
1039
 
 
1040
1043
    class SetSecret(PropertySetterValue):
1041
1044
        propname = "Secret"
1042
1045
 
1050
1053
            self._vts = value.read()
1051
1054
            value.close()
1052
1055
 
1053
 
 
1054
1056
    class PropertySetterValueMilliseconds(PropertySetterValue):
1055
1057
        """Abstract class for PropertySetterValue taking a value
1056
1058
argument as a datetime.timedelta() but should store it as
1065
1067
            "When setting, convert value from a datetime.timedelta"
1066
1068
            self._vts = int(round(value.total_seconds() * 1000))
1067
1069
 
1068
 
 
1069
1070
    class SetTimeout(PropertySetterValueMilliseconds):
1070
1071
        propname = "Timeout"
1071
1072
 
1072
 
 
1073
1073
    class SetExtendedTimeout(PropertySetterValueMilliseconds):
1074
1074
        propname = "ExtendedTimeout"
1075
1075
 
1076
 
 
1077
1076
    class SetInterval(PropertySetterValueMilliseconds):
1078
1077
        propname = "Interval"
1079
1078
 
1080
 
 
1081
1079
    class SetApprovalDelay(PropertySetterValueMilliseconds):
1082
1080
        propname = "ApprovalDelay"
1083
1081
 
1084
 
 
1085
1082
    class SetApprovalDuration(PropertySetterValueMilliseconds):
1086
1083
        propname = "ApprovalDuration"
1087
1084
 
1627
1624
        finally:
1628
1625
            dbus_logger.removeFilter(counting_handler)
1629
1626
 
1630
 
        self.assertNotIsInstance(e, dbus.ConnectFailed)
 
1627
        self.assertNotIsInstance(e.exception, dbus.ConnectFailed)
1631
1628
 
1632
1629
        # Make sure the dbus logger was suppressed
1633
1630
        self.assertEqual(0, counting_handler.count)
1758
1755
        self.assertIs(ret, expected_method_return)
1759
1756
 
1760
1757
    def test_call_method_handles_exception(self):
1761
 
        dbus_logger = logging.getLogger("dbus.proxies")
1762
 
 
1763
1758
        def func():
1764
1759
            raise gi.repository.GLib.Error()
1765
1760
 
1770
1765
            self.call_method(bus, "methodname", "busname",
1771
1766
                             "objectpath", "interface")
1772
1767
 
1773
 
        self.assertNotIsInstance(e, dbus.ConnectFailed)
 
1768
        self.assertNotIsInstance(e.exception, dbus.ConnectFailed)
1774
1769
 
1775
1770
    def test_get_converts_to_correct_exception(self):
1776
1771
        bus = pydbus_adapter.SystemBus(
2432
2427
        busname = "se.recompile.Mandos"
2433
2428
        client_interface = "se.recompile.Mandos.Client"
2434
2429
        command.Approve().run(self.bus.clients, self.bus)
 
2430
        self.assertTrue(self.bus.clients)
2435
2431
        for clientpath in self.bus.clients:
2436
2432
            self.assertIn(("Approve", busname, clientpath,
2437
2433
                           client_interface, (True,)), self.bus.calls)
2440
2436
        busname = "se.recompile.Mandos"
2441
2437
        client_interface = "se.recompile.Mandos.Client"
2442
2438
        command.Deny().run(self.bus.clients, self.bus)
 
2439
        self.assertTrue(self.bus.clients)
2443
2440
        for clientpath in self.bus.clients:
2444
2441
            self.assertIn(("Approve", busname, clientpath,
2445
2442
                           client_interface, (False,)),
2446
2443
                          self.bus.calls)
2447
2444
 
2448
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()
2449
2450
        command.Remove().run(self.bus.clients, self.bus)
2450
 
        for clientpath in self.bus.clients:
2451
 
            self.assertIn(("RemoveClient", dbus_busname,
2452
 
                           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,
2453
2455
                           (clientpath,)), self.bus.calls)
2454
2456
 
2455
2457
    expected_json = {
2657
2659
        else:
2658
2660
            cmd_args = [() for x in range(len(self.values_to_get))]
2659
2661
            values_to_get = self.values_to_get
 
2662
        self.assertTrue(values_to_get)
2660
2663
        for value_to_get, cmd_arg in zip(values_to_get, cmd_args):
2661
2664
            for clientpath in self.bus.clients:
2662
2665
                self.bus.clients[clientpath][self.propname] = (
2663
2666
                    Unique())
2664
2667
            self.command(*cmd_arg).run(self.bus.clients, self.bus)
 
2668
            self.assertTrue(self.bus.clients)
2665
2669
            for clientpath in self.bus.clients:
2666
2670
                value = (self.bus.clients[clientpath]
2667
2671
                         [self.propname])
2726
2730
class TestSetSecretCmd(TestPropertySetterCmd):
2727
2731
    command = command.SetSecret
2728
2732
    propname = "Secret"
2729
 
    values_to_set = [io.BytesIO(b""),
2730
 
                     io.BytesIO(b"secret\0xyzzy\nbar")]
2731
 
    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)
2732
2739
 
2733
2740
 
2734
2741
class TestSetTimeoutCmd(TestPropertySetterCmd):
2787
2794
 
2788
2795
 
2789
2796
 
2790
 
def should_only_run_tests():
 
2797
def parse_test_args():
 
2798
    # type: () -> argparse.Namespace
2791
2799
    parser = argparse.ArgumentParser(add_help=False)
2792
 
    parser.add_argument("--check", action='store_true')
 
2800
    parser.add_argument("--check", action="store_true")
 
2801
    parser.add_argument("--prefix", )
2793
2802
    args, unknown_args = parser.parse_known_args()
2794
 
    run_tests = args.check
2795
 
    if run_tests:
2796
 
        # Remove --check argument from sys.argv
 
2803
    if args.check:
 
2804
        # Remove test options from sys.argv
2797
2805
        sys.argv[1:] = unknown_args
2798
 
    return run_tests
 
2806
    return args
2799
2807
 
2800
2808
# Add all tests from doctest strings
2801
2809
def load_tests(loader, tests, none):
2804
2812
    return tests
2805
2813
 
2806
2814
if __name__ == "__main__":
 
2815
    options = parse_test_args()
2807
2816
    try:
2808
 
        if should_only_run_tests():
2809
 
            # Call using ./tdd-python-script --check [--verbose]
2810
 
            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=[""])
2811
2829
        else:
2812
2830
            main()
2813
2831
    finally:
2814
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: