aboutsummaryrefslogtreecommitdiffstats
path: root/packaging/checks/plugin-permissions-live.py
blob: 5d44ab2cb50d1bd6ef3e0464f3d1fe58365bd7ac (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
#!/usr/bin/env python3
"""Live check: do the plugins sl0ppi ships start without a permission nag, and
does that trust stop at our own plugin directory?

Both halves matter. The first is the feature: pane-finder asks for three
permissions the moment it loads, and answering that prompt on first launch (per
machine, per session cache) is not something a shipped keybinding should make
you do. The second is the limit: "we trust the plugins we ship" must not decay
into "we trust any wasm anyone drops on this box", so the same plugin loaded
from outside the plugin dir must still ask.

Usage: plugin-permissions-live.py [path-to-sl0ppij] [path-to-pane-finder.wasm]
"""

import fcntl
import os
import pty
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import termios
import time

HERE = os.path.dirname(os.path.abspath(__file__))
BIN = os.path.abspath(
    sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "../../sl0ppij/target/fastdev/sl0ppij")
)
WASM = os.path.abspath(
    sys.argv[2]
    if len(sys.argv) > 2
    else os.path.join(HERE, "../../sl0ppij/target/wasm32-wasip1/release/zj-pane-finder.wasm")
)
ROWS, COLS = 30, 100
SESSION = f"sl0ptest-perms-{os.getpid()}"

CONFIG = """
pane_frames true
pane_frame_style "full"
default_mode "locked"
show_startup_tips false
"""

# what the permission prompt says (plugin_pane.rs: display_request_permission_message)
NAG = ("asks permission to", "This plugin asks permission to", "Allow?")
# what the pane finder draws once it is actually running
RUNNING = "type to filter panes"


def screen_text(raw):
    """Printable text of the stream, with escape sequences stripped.

    Coarser than the split-button check's grid, and that is on purpose: this one
    asks "does this string appear", not "at which column", so a torn repaint can
    only ever hide a nag we then fail to find -- and we assert the plugin's own
    output too, which would go missing at the same time.
    """
    text = re.sub(r"\x1b\][^\x07\x1b]*(\x07|\x1b\\)", "", raw)
    text = re.sub(r"\x1b\[[0-9;?]*[a-zA-Z]", "", text)
    text = re.sub(r"\x1b[()][0-9A-B]", "", text)
    return text.replace("\r", "\n")


def read_until(fd, predicate, seconds=25):
    """Accumulate output until `predicate` is happy or the deadline passes."""
    buffered = ""
    deadline = time.time() + seconds
    while time.time() < deadline:
        time.sleep(0.2)
        try:
            buffered += os.read(fd, 1 << 20).decode("utf8", "replace")
        except BlockingIOError:
            pass
        except OSError:
            break
        if predicate(screen_text(buffered)):
            break
    return screen_text(buffered)


def main():
    if not os.path.isfile(WASM):
        print(f"FAIL: no pane-finder wasm at {WASM} (build it with `make plugins`)")
        return 1

    tmp = tempfile.mkdtemp(prefix="sl0p-perms-")
    config_dir = os.path.join(tmp, "config")
    shipped = os.path.join(config_dir, "plugins")  # where the installer puts ours
    elsewhere = os.path.join(tmp, "elsewhere")  # a plugin we do NOT ship
    os.makedirs(shipped)
    os.makedirs(elsewhere)
    config_file = os.path.join(config_dir, "config.kdl")
    with open(config_file, "w") as f:
        f.write(CONFIG)
    shutil.copy(WASM, os.path.join(shipped, "pane-finder.wasm"))
    shutil.copy(WASM, os.path.join(elsewhere, "pane-finder.wasm"))

    env = dict(os.environ)
    env.update(
        {
            "SL0PPIJ_SOCKET_DIR": os.path.join(tmp, "sock"),
            "SL0PPIJ_CONFIG_FILE": config_file,
            "SL0PPIJ_CONFIG_DIR": config_dir,
            "SL0PPIJ_CACHE_DIR": os.path.join(tmp, "cache"),
            "XDG_CACHE_HOME": os.path.join(tmp, "cache"),  # the permission cache lives here
            "TERM": "xterm-256color",
            "SHELL": "/bin/sh",
        }
    )

    pid, fd = pty.fork()
    if pid == 0:
        os.execvpe(BIN, [BIN, "-s", SESSION], env)
    fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0))
    os.set_blocking(fd, False)

    failures = []
    try:
        read_until(fd, lambda text: "$" in text, seconds=20)

        def launch(path):
            subprocess.run(
                [
                    BIN,
                    "-s",
                    SESSION,
                    "action",
                    "launch-or-focus-plugin",
                    "--floating",
                    f"file:{path}",
                ],
                env=env,
                capture_output=True,
                text=True,
            )

        # 1. a plugin we ship: must run, must not ask
        launch(os.path.join(shipped, "pane-finder.wasm"))
        text = read_until(
            fd, lambda t: RUNNING in t or any(n in t for n in NAG), seconds=25
        )
        nagged = [n for n in NAG if n in text]
        running = RUNNING in text
        print(f"shipped plugin:  running={running} nagged={bool(nagged)}")
        if nagged:
            failures.append(f"the plugin we ship asked for permissions: {nagged}")
        if not running:
            failures.append("the plugin we ship did not start")
            print("\n".join(text.splitlines()[-25:]))

        # 2. the same plugin from outside our plugin dir: must still ask, or
        #    "trusted" would just mean "any wasm on this machine"
        subprocess.run(
            [BIN, "-s", SESSION, "action", "close-pane"], env=env, capture_output=True
        )
        time.sleep(1)
        launch(os.path.join(elsewhere, "pane-finder.wasm"))
        text = read_until(fd, lambda t: any(n in t for n in NAG), seconds=25)
        nagged = [n for n in NAG if n in text]
        print(f"unshipped plugin: nagged={bool(nagged)}")
        if not nagged:
            failures.append(
                "a plugin outside the shipped plugin dir was granted permissions silently"
            )
            print("\n".join(text.splitlines()[-25:]))
        return failures
    finally:
        subprocess.run(
            [BIN, "kill-session", SESSION], env=env, capture_output=True, text=True
        )
        # `kill-session NAME`, not `-s NAME kill-session`: the latter parses
        # but leaves the session running. Then DELETE it -- a killed session
        # stays in `list-sessions` as "EXITED - attach to resurrect", so a day
        # of check runs silently fills the user's session picker.
        subprocess.run([BIN, "delete-session", SESSION], env=env, capture_output=True, text=True)
        try:
            os.close(fd)
        except OSError:
            pass
        os.waitpid(pid, os.WNOHANG)
        time.sleep(1)
        # never `pkill -f`: it matches this script's own command line
        ps = subprocess.run(["ps", "-eo", "pid,comm,args"], capture_output=True, text=True).stdout
        for line in ps.splitlines():
            fields = line.split(None, 2)
            if len(fields) == 3 and fields[1] == os.path.basename(BIN) and tmp in fields[2]:
                try:
                    os.kill(int(fields[0]), 15)
                except (ProcessLookupError, ValueError, PermissionError):
                    pass
        shutil.rmtree(tmp, ignore_errors=True)


if __name__ == "__main__":
    problems = main()
    if problems == 1:
        sys.exit(1)
    for p in problems:
        print(f"FAIL: {p}")
    if not problems:
        print("ok: shipped plugins start silently, others still ask")
    sys.exit(1 if problems else 0)