Wren Linux

wren-linux / bin / wayland-restrict

#!/bin/bash
# wayland-restrict SOCKET APP_ID: written by Wren Linux (parts/incognito.sh and
# extras/media.sh).
# Makes a second Wayland socket at SOCKET that sway marks as "sandboxed"
# (the wp_security_context_v1 protocol). A program connected to it can open
# windows, but sway doesn't offer it the privileged protocols: reading the
# screen, typing for you, locking or unlocking the session, listing your
# windows. Your real socket gives all of that to anyone who can open it.
# Prints "ready" when the socket works, then stays until its standard input
# closes or it is killed; the socket stops accepting connections then.
# (The program is Python, kept inside this script so that it installs like the
# other commands.)
set -euo pipefail
code=$(cat <<'PY'
import os
import select
import socket
import struct
import sys

MANAGER = "wp_security_context_manager_v1"
DISPLAY, REGISTRY, CALLBACK1, MGR, CTX, CALLBACK2 = 1, 2, 3, 4, 5, 6


def die(msg):
    print("wayland-restrict: " + msg, file=sys.stderr)
    sys.exit(1)


def string(s):
    b = s.encode() + b"\0"
    return struct.pack("I", len(b)) + b + b"\0" * (-len(b) % 4)


def message(obj, op, payload=b""):
    return struct.pack("II", obj, ((8 + len(payload)) << 16) | op) + payload


class Conn:
    def __init__(self, path):
        self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self.s.connect(path)
        self.buf = b""

    def send(self, data, fds=()):
        self.s.sendmsg(
            [data],
            [(socket.SOL_SOCKET, socket.SCM_RIGHTS, struct.pack("%di" % len(fds), *fds))] if fds else [])

    def events(self):
        """Yields (object, opcode, payload) until the compositor hangs up."""
        while True:
            while len(self.buf) >= 8:
                obj, sizeop = struct.unpack("II", self.buf[:8])
                size, op = sizeop >> 16, sizeop & 0xFFFF
                if size < 8:
                    die("the compositor sent a broken message.")
                if len(self.buf) < size:
                    break
                payload, self.buf = self.buf[8:size], self.buf[size:]
                yield obj, op, payload
            data = self.s.recv(4096)
            if not data:
                die("the compositor closed the connection.")
            self.buf += data

    def roundtrip(self, callback_id, on_event=None):
        self.send(message(DISPLAY, 0, struct.pack("I", callback_id)))
        for obj, op, payload in self.events():
            if obj == DISPLAY and op == 0:
                code = struct.unpack("II", payload[4:12])[1]
                text = payload[16:].split(b"\0")[0].decode(errors="replace")
                die("the compositor refused it (error %d): %s" % (code, text))
            if obj == callback_id and op == 0:
                return
            if on_event:
                on_event(obj, op, payload)


def main():
    if len(sys.argv) != 3:
        die("usage: wayland-restrict SOCKET APP_ID")
    path, app_id = sys.argv[1], sys.argv[2]
    rt, wl = os.environ.get("XDG_RUNTIME_DIR"), os.environ.get("WAYLAND_DISPLAY")
    if not rt or not wl or "/" in wl:
        die("needs your sway session (XDG_RUNTIME_DIR and WAYLAND_DISPLAY).")

    found = {}

    def on_global(obj, op, payload):
        if obj == REGISTRY and op == 0:
            name, n = struct.unpack("II", payload[:8])
            iface = payload[8:8 + n].rstrip(b"\0").decode(errors="replace")
            version = struct.unpack("I", payload[8 + n + (-n % 4):][:4])[0]
            found[iface] = (name, version)

    conn = Conn(os.path.join(rt, wl))
    conn.send(message(DISPLAY, 1, struct.pack("I", REGISTRY)))
    conn.roundtrip(CALLBACK1, on_global)
    if MANAGER not in found:
        die("your compositor doesn't offer %s (sway 1.9 or newer does)." % MANAGER)

    os.umask(0o077)
    try:
        os.unlink(path)
    except FileNotFoundError:
        pass
    listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    listener.bind(path)
    listener.listen(16)
    # The compositor stops listening when it sees this pipe's far end close
    close_r, close_w = os.pipe()

    name = found[MANAGER][0]
    conn.send(message(REGISTRY, 0, struct.pack("I", name) + string(MANAGER) +
                      struct.pack("II", 1, MGR)))
    conn.send(message(MGR, 1, struct.pack("I", CTX)), [listener.fileno(), close_r])
    conn.send(message(CTX, 1, string("wren")) +
              message(CTX, 2, string(app_id)) +
              message(CTX, 3, string("%s-%d" % (app_id, os.getpid()))) +
              message(CTX, 4))
    conn.roundtrip(CALLBACK2)
    os.close(close_r)

    print("ready", flush=True)
    try:
        # Until our parent lets go of stdin, or the compositor goes away
        while True:
            r, _, _ = select.select([0, conn.s], [], [])
            if 0 in r and not os.read(0, 4096):
                break
            if conn.s in r and not conn.s.recv(4096):
                break
    finally:
        os.close(close_w)
        try:
            os.unlink(path)
        except FileNotFoundError:
            pass


if __name__ == "__main__":
    try:
        main()
    except (ConnectionError, OSError) as e:
        die(str(e))
PY
)
exec python3 -I -c "$code" "$@"