mirror of
https://github.com/tmux/tmux.git
synced 2026-10-16 00:14:23 +00:00
221 lines
9.4 KiB
Bash
221 lines
9.4 KiB
Bash
#!/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
|