diff --git a/regress/client-readonly.sh b/regress/client-readonly.sh new file mode 100644 index 000000000..fb31aaba9 --- /dev/null +++ b/regress/client-readonly.sh @@ -0,0 +1,220 @@ +#!/bin/sh + +# Exercise the same CLIENT_READONLY restrictions used by server-access -r +# without needing another UID or privileges. + +PATH=/bin:/usr/bin:/usr/local/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -Ltest-readonly$$ -f/dev/null" +trap '$TMUX kill-server 2>/dev/null' 0 1 15 + +python3 - $TMUX <<'PY' || exit 1 +import os +import queue +import subprocess +import sys +import tempfile +import threading +import time + +tmux = sys.argv[1:] +failures = [] +clients = [] + +def run(*args): + return subprocess.check_output(tmux + list(args), text=True).strip() + +def check(condition, message): + if not condition: + failures.append(message) + print(message, file=sys.stderr) + +class Control: + def __init__(self, *args, env=None): + self.process = subprocess.Popen(tmux + ["-C"] + list(args), + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, env=env) + clients.append(self.process) + self.lines = queue.Queue() + def read(): + for line in self.process.stdout: + self.lines.put(line.rstrip("\n")) + self.lines.put(None) + threading.Thread(target=read, daemon=True).start() + error, output = self.reply() + assert not error, output + listing = run("list-clients", "-F", "#{client_pid}:#{client_name}") + self.name = next(line.split(":", 1)[1] for line in listing.splitlines() + if line.startswith(str(self.process.pid) + ":")) + + def reply(self): + guard = None + output = [] + while True: + line = self.lines.get(timeout=10) + assert line is not None, "control client exited before its reply" + if guard is None: + if line.startswith("%begin "): + guard = line[7:] + elif line in ("%end " + guard, "%error " + guard): + return line.startswith("%error "), output + else: + output.append(line) + + def command(self, command): + self.process.stdin.write(command + "\n") + self.process.stdin.flush() + return self.reply() + + def allow(self, command): + error, output = self.command(command) + check(not error, "command failed: " + command + ": " + str(output)) + return output + + def deny(self, command): + error, output = self.command(command) + check(error and "client is read-only" in output, + "read-only command was not rejected: " + command) + +def environment(session): + return run("show-environment", "-t", session, "DISPLAY") + +def state(name): + return [line for line in run("list-clients", "-F", + "#{client_name}|#{session_name}|#{client_key_table}|#{client_readonly}") + .splitlines() if line.startswith(name + "|")] + +try: + with tempfile.TemporaryDirectory(prefix="tmux-readonly-") as directory: + for session in ("alpha", "beta"): + run("new-session", "-d", "-s", session, "-c", directory, + "exec sleep 1000") + run("set-option", "-t", session, "update-environment", "DISPLAY") + run("set-environment", "-t", session, "DISPLAY", "owner:0") + + owner = Control("attach", "-E", "-t", "alpha") + viewer_env = dict(os.environ, DISPLAY="viewer:9") + viewer = Control("attach", "-r", "-t", "alpha", env=viewer_env) + check(environment("alpha") == "DISPLAY=owner:0", + "initial read-only attach updated the session environment") + + # Format jobs must be suppressed even when defaults or a client loop + # describe a writable client. Ordinary formats must still work. + marker = os.path.join(directory, "readonly-job") + viewer.allow("list-clients -F '#(touch " + marker + ")'") + viewer.allow("list-clients -f '#(touch " + marker + ")'") + viewer.allow("list-clients -F '#{C:#(touch " + marker + ")}'") + output = viewer.allow("list-clients -F '#{client_name}:#{session_name}'") + check(owner.name + ":alpha" in output, "ordinary formats failed") + time.sleep(0.2) + check(not os.path.exists(marker), "read-only format started a job") + + # A read-only sender cannot change even a writable target client. + before = state(owner.name) + viewer.deny("switch-client -c " + owner.name + " -t beta") + viewer.deny("switch-client -c " + owner.name + " -T copy-mode") + viewer.deny("switch-client -c " + owner.name + " -r") + viewer.deny("send-keys -c " + owner.name + " -t alpha:0 x") + check(state(owner.name) == before, "another client's state changed") + if any(line.endswith("|1") for line in state(owner.name)): + run("switch-client", "-c", owner.name, "-r", "-E") + run("switch-client", "-c", owner.name, "-t", "alpha", "-E") + run("switch-client", "-c", owner.name, "-T", "root") + + # Self switching and reattaching remain available, but do not import + # or remove environment entries from either destination session. + for session in ("alpha", "beta"): + run("set-environment", "-t", session, "DISPLAY", "owner:0") + viewer.allow("switch-client -t beta") + check(viewer.name + "|beta|root|1" in state(viewer.name), + "read-only client could not switch its own session") + check(environment("beta") == "DISPLAY=owner:0", + "read-only switch updated the session environment") + viewer.allow("switch-client -T copy-mode") + check(viewer.name + "|beta|copy-mode|1" in state(viewer.name), + "read-only client could not change its own key table") + viewer.allow("attach -t alpha") + check(environment("alpha") == "DISPLAY=owner:0", + "read-only reattach updated the session environment") + + empty_env = dict(os.environ) + empty_env.pop("DISPLAY", None) + other = Control("attach", "-f", "read-only", "-t", "alpha", + env=empty_env) + check(environment("alpha") == "DISPLAY=owner:0", + "read-only attach removed a session environment entry") + + # Explicit window/pane targets must not change the shared selection. + run("new-window", "-d", "-t", "alpha:1", "exec sleep 1000") + run("split-window", "-d", "-t", "alpha:1", "exec sleep 1000") + selection = run("display-message", "-p", "-t", "alpha", + "#{window_id}|#{pane_id}") + window, pane = run("display-message", "-p", "-t", "alpha:1.1", + "#{window_id}|#{pane_id}").split("|") + viewer.deny("attach -t alpha:1.1") + viewer.deny("switch-client -t alpha:1.1") + viewer.deny("attach -t " + window) + viewer.deny("attach -t " + pane) + viewer.deny("switch-client -t " + window) + viewer.deny("switch-client -t " + pane) + run("select-pane", "-m", "-t", "alpha:1.1") + viewer.deny("attach -t '{marked}'") + viewer.deny("switch-client -t '{marked}'") + run("select-pane", "-M") + check(run("display-message", "-p", "-t", "alpha", + "#{window_id}|#{pane_id}") == selection, + "read-only client changed the shared window/pane selection") + viewer.allow("attach -t alpha:0.0") + viewer.allow("switch-client -t alpha:0.0") + + # Check attach options separately, so a guard for one does not hide + # missing checks for the others. + path = run("display-message", "-p", "-t", "alpha", "#{session_path}") + viewer.deny("attach -c /tmp -t alpha") + check(run("display-message", "-p", "-t", "alpha", "#{session_path}") + == path, "read-only attach changed the session path") + viewer.deny("attach -d -t alpha") + viewer.deny("attach -x -t alpha") + check(owner.process.poll() is None, "read-only attach detached owner") + check(other.process.poll() is None, "read-only attach detached viewer") + viewer.deny("new-window") + viewer.deny("run-shell 'touch " + marker + "'") + viewer.allow("copy-mode -t alpha:0") + viewer.deny("send-keys -X -c " + owner.name + " -t alpha:0 cursor-down") + viewer.allow("send-keys -X -t alpha:0 cursor-down") + + # The server owner may still make their own client writable again. + # Writable commands must continue to import the environment and run + # jobs as before. + viewer.allow("switch-client -r -t beta") + check(environment("beta") == "DISPLAY=viewer:9", + "writable switch did not update the session environment") + writable_marker = os.path.join(directory, "writable-job") + viewer.allow("list-clients -F '#(touch " + writable_marker + ")'") + for i in range(100): + if os.path.exists(writable_marker): + break + time.sleep(0.05) + check(os.path.exists(writable_marker), "writable format job did not run") + + # Check permissions when a command executes, including commands that + # were queued while this client was still writable. + viewer.process.stdin.write("attach -r -t alpha\nnew-window\n") + viewer.process.stdin.flush() + error, output = viewer.reply() + check(not error, "queued read-only attach failed") + error, output = viewer.reply() + check(error and "client is read-only" in output, + "queued command bypassed the read-only restriction") +finally: + subprocess.run(tmux + ["kill-server"], stderr=subprocess.DEVNULL) + for process in clients: + process.stdin.close() + process.wait(timeout=10) + +sys.exit(bool(failures)) +PY +exit 0 diff --git a/regress/control-client-start-fail.sh b/regress/control-client-start-fail.sh new file mode 100755 index 000000000..b19a8fa7b --- /dev/null +++ b/regress/control-client-start-fail.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# The server cannot create its socket, so it sets the control client to exit +# before the client completes identification. The exit check must not use the +# control state before MSG_IDENTIFY_DONE creates it. The client must get the +# reason for the failure in %exit. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) +SOCK=$DIR/missing/sock + +OUT=$($TEST_TMUX -f/dev/null -S$SOCK -C new-session &1) +rm -rf $DIR +[ "$OUT" = "%exit error creating $SOCK (No such file or directory)" ] || exit 1 + +exit 0