Test for 5740 and readonly.

This commit is contained in:
Nicholas Marriott
2026-10-15 08:48:31 +01:00
parent ad1d00ae34
commit bc632ff6dc
2 changed files with 240 additions and 0 deletions

220
regress/client-readonly.sh Normal file
View File

@@ -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