tmux-party: share a tmux session with people on the same UNIX host. Single-file POSIX shell (party) with a filesystem + tmux server-access trust model. See README.md.
812 lines
25 KiB
Bash
812 lines
25 KiB
Bash
#!/usr/bin/env bats
|
|
#
|
|
# End-to-end host -> list -> close roundtrip with a stubbed PARTY_TMUX.
|
|
# The dispatch layer talks to tmux exclusively through PARTY_TMUX, so we
|
|
# hand it a recorder script and verify the side effects party owns
|
|
# directly: per-party private dirs, the roster file inside them, the
|
|
# notify helper, and the tmux command stream itself.
|
|
|
|
load 'helpers'
|
|
|
|
setup() {
|
|
setup_party_sandbox
|
|
# Use the caller's primary group, which they are guaranteed to be in.
|
|
# require_caller_in_group and chgrp both succeed regardless of the
|
|
# default 'party' group existing on the host.
|
|
export TMUX_PARTY_GROUP="$(id -gn)"
|
|
|
|
# Spawn a long-lived sleeper so cmd_list's is_party_alive check sees a
|
|
# live PID for the "fake server". setup_party_sandbox provides PARTY_TMP.
|
|
( exec sleep 60 ) &
|
|
export FAKE_SERVER_PID=$!
|
|
|
|
# Recorder: appends every invocation to TMUX_LOG, fakes the few tmux
|
|
# subcommands cmd_host depends on (creating a placeholder socket file,
|
|
# answering display-message -p '#{pid}' with the sleeper's PID so
|
|
# is_party_alive sees it as live), and returns 0 for the rest.
|
|
export TMUX_LOG="$PARTY_TMP/tmux.log"
|
|
: > "$TMUX_LOG"
|
|
cat > "$PARTY_TMP/tmux-stub" <<'STUB'
|
|
#!/bin/sh
|
|
printf '%s\n' "$*" >> "$TMUX_LOG"
|
|
sock=
|
|
prev=
|
|
for a in "$@"; do
|
|
if [ "$prev" = "-S" ]; then sock="$a"; fi
|
|
prev="$a"
|
|
done
|
|
case "$*" in
|
|
*"new-session -d "*)
|
|
# cmd_host expects the socket file to exist for apply_party_perms_file.
|
|
[ -n "$sock" ] && : > "$sock"
|
|
;;
|
|
*"display-message -p "*)
|
|
# Caller is fishing for #{pid}; emit the sleeper's PID.
|
|
echo "$FAKE_SERVER_PID"
|
|
;;
|
|
*"set-hook -g client-attached "*)
|
|
# Optional injection point for the rollback test: a failure here
|
|
# exits cmd_host between new-session and roster_write.
|
|
[ -n "${FAIL_ON_SETHOOK:-}" ] && exit 17
|
|
;;
|
|
esac
|
|
exit 0
|
|
STUB
|
|
chmod +x "$PARTY_TMP/tmux-stub"
|
|
export PARTY_TMUX="$PARTY_TMP/tmux-stub"
|
|
}
|
|
|
|
teardown() {
|
|
if [ -n "${FAKE_SERVER_PID:-}" ]; then
|
|
kill "$FAKE_SERVER_PID" 2>/dev/null || true
|
|
wait "$FAKE_SERVER_PID" 2>/dev/null || true
|
|
fi
|
|
teardown_party_sandbox
|
|
}
|
|
|
|
# Helper: per-party roster path under the new layout.
|
|
party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
|
|
|
|
@test "host -> list -> close roundtrip with stubbed tmux" {
|
|
# host
|
|
run "$PARTY_BIN" host roundtrip
|
|
[ "$status" -eq 0 ]
|
|
|
|
rec=$(party_rec roundtrip)
|
|
[ -f "$rec" ]
|
|
grep -q "^HOST_USER=$USER$" "$rec"
|
|
grep -q "^PARTY_NAME=roundtrip$" "$rec"
|
|
grep -q "^GROUP=$TMUX_PARTY_GROUP$" "$rec"
|
|
grep -q "^SERVER_PID=[0-9][0-9]*$" "$rec"
|
|
|
|
# The per-party directory and its socket placeholder are present, mode
|
|
# 0750 on the dir.
|
|
party_dir="$PARTY_SOCKET_DIR/party-$USER:roundtrip.d"
|
|
[ -d "$party_dir" ]
|
|
[ -f "$party_dir/sock" ]
|
|
[ -x "$party_dir/.party-notify" ]
|
|
m=$(stat -c '%a' "$party_dir" 2>/dev/null || stat -f '%Lp' "$party_dir")
|
|
[ "$m" = "750" ]
|
|
|
|
# tmux command stream: the host is on the access list, hooks are set,
|
|
# status-right widget is configured.
|
|
grep -q "server-access -aw $USER" "$TMUX_LOG"
|
|
grep -q "set-hook -g client-attached" "$TMUX_LOG"
|
|
grep -q "set-hook -g client-detached" "$TMUX_LOG"
|
|
grep -q "status-right" "$TMUX_LOG"
|
|
|
|
# list shows the running party.
|
|
run "$PARTY_BIN" list
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"roundtrip"* ]]
|
|
|
|
# close removes the per-party directory entirely (which takes the
|
|
# roster file with it) and runs kill-server.
|
|
run "$PARTY_BIN" close roundtrip
|
|
[ "$status" -eq 0 ]
|
|
[ ! -d "$party_dir" ]
|
|
[ ! -f "$rec" ]
|
|
grep -q "kill-server" "$TMUX_LOG"
|
|
}
|
|
|
|
@test "host --group <name> records the chosen group in the roster" {
|
|
# The stub doesn't care which group we pick — exercise the path that
|
|
# plumbs --group through to the GROUP= field. Use the caller's primary
|
|
# group so chgrp succeeds.
|
|
primary=$(id -gn)
|
|
run "$PARTY_BIN" host grouped --group "$primary"
|
|
[ "$status" -eq 0 ]
|
|
|
|
rec=$(party_rec grouped)
|
|
[ -f "$rec" ]
|
|
grep -q "^GROUP=$primary$" "$rec"
|
|
|
|
"$PARTY_BIN" close grouped
|
|
}
|
|
|
|
@test "host with --group works without any pre-existing shared install dir" {
|
|
# Regression: the old layout required /tmp/roster to be installed at
|
|
# mode 1770 g=$TMUX_PARTY_GROUP at install time. The new layout has
|
|
# nothing of the sort — every party is self-contained under PARTY_SOCKET_DIR.
|
|
# Confirm `party host --group` succeeds with a clean sandbox that
|
|
# never had a shared roster directory.
|
|
primary=$(id -gn)
|
|
run "$PARTY_BIN" host noshareddir --group "$primary"
|
|
[ "$status" -eq 0 ]
|
|
rec=$(party_rec noshareddir)
|
|
[ -f "$rec" ]
|
|
"$PARTY_BIN" close noshareddir
|
|
}
|
|
|
|
@test "host refuses a non-trustworthy PARTY_SOCKET_DIR" {
|
|
bad="$PARTY_TMP/bad"
|
|
mkdir -p "$bad"
|
|
chmod 0777 "$bad" # not sticky, world-writable
|
|
PARTY_SOCKET_DIR="$bad" run "$PARTY_BIN" host shouldfail
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"refusing to operate"* ]]
|
|
}
|
|
|
|
@test "host refuses group-writable, non-sticky PARTY_SOCKET_DIR" {
|
|
# Owned by us, mode 0770, no sticky. The parent's group may differ
|
|
# from TMUX_PARTY_GROUP, so its members would be outside the trust
|
|
# perimeter yet able to rename or unlink the per-party dir.
|
|
bad="$PARTY_TMP/groupw"
|
|
mkdir -p "$bad"
|
|
chmod 0770 "$bad"
|
|
PARTY_SOCKET_DIR="$bad" run "$PARTY_BIN" host shouldfail
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"refusing to operate"* ]]
|
|
}
|
|
|
|
@test "host party_dir ends at mode 0750 even with permissive umask" {
|
|
# Regression for the umask race: with umask 0002, plain `mkdir` would
|
|
# produce 0775 until the later chmod 0750 closed the window. mkdir -m
|
|
# 0700 in cmd_host removes the window entirely — and the postcondition
|
|
# is still 0750.
|
|
( umask 0002 && "$PARTY_BIN" host umasked )
|
|
party_dir="$PARTY_SOCKET_DIR/party-$USER:umasked.d"
|
|
m=$(stat -c '%a' "$party_dir" 2>/dev/null || stat -f '%Lp' "$party_dir")
|
|
[ "$m" = "750" ]
|
|
"$PARTY_BIN" close umasked
|
|
}
|
|
|
|
@test "host refuses to bind a socket path past AF_UNIX sun_path" {
|
|
# Build a long-but-trustworthy parent under PARTY_TMP, then host with a
|
|
# near-max-length name. Total length should exceed the ~100-byte cap
|
|
# cmd_host enforces. The error must come from us, not from tmux.
|
|
pad=$(printf '%080s' '' | tr ' ' p) # 80-char component
|
|
long="$PARTY_TMP/$pad"
|
|
mkdir "$long" && chmod 0700 "$long"
|
|
longname=$(printf '%050s' '' | tr ' ' n) # 50 chars, within validator's 63
|
|
PARTY_SOCKET_DIR="$long" run "$PARTY_BIN" host "$longname"
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"socket path too long"* ]]
|
|
# And the tmux stub never got invoked for new-session.
|
|
! grep -q "new-session" "$TMUX_LOG"
|
|
}
|
|
|
|
@test "roster record is mode 0640, not group-writable" {
|
|
# If the roster is g+w, peers can rewrite SOCKET= to point cmd_close at
|
|
# arbitrary host-owned paths. Lock it to g+r.
|
|
"$PARTY_BIN" host modecheck
|
|
rec=$(party_rec modecheck)
|
|
m=$(stat -c '%a' "$rec" 2>/dev/null || stat -f '%Lp' "$rec")
|
|
[ "$m" = "640" ]
|
|
"$PARTY_BIN" close modecheck
|
|
}
|
|
|
|
@test "join refuses when caller is not in the party's recorded group" {
|
|
# The cmd_join group check uses RR_GROUP (per-party), not env
|
|
# TMUX_PARTY_GROUP. Forge a roster record whose GROUP= names a group
|
|
# nobody is in, point SERVER_PID at the live sleeper so it looks
|
|
# alive, and verify cmd_join rejects with the friendly diagnostic
|
|
# before any tmux work.
|
|
bogus="party_no_such_group_$$"
|
|
party_dir="$PARTY_SOCKET_DIR/party-$USER:groupgated.d"
|
|
mkdir -p "$party_dir"
|
|
chmod 0750 "$party_dir"
|
|
: > "$party_dir/sock"
|
|
rec="$party_dir/roster"
|
|
cat > "$rec" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=groupgated
|
|
SOCKET=$party_dir/sock
|
|
SERVER_PID=$FAKE_SERVER_PID
|
|
GROUP=$bogus
|
|
CREATED=2026-04-28T00:00:00Z
|
|
EOF
|
|
|
|
run "$PARTY_BIN" join groupgated
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"uses group '$bogus'"* ]]
|
|
[[ "$output" == *"not a member"* ]]
|
|
}
|
|
|
|
@test "close ignores forged SOCKET= field; uses derived path" {
|
|
# roster_read derives RR_SOCKET from $d/sock and ignores the file's
|
|
# SOCKET= field, and cmd_close derives both paths from $USER + $name
|
|
# without consulting the roster at all. A forged SOCKET= cannot
|
|
# redirect the destructive ops at unrelated paths.
|
|
"$PARTY_BIN" host tampered
|
|
|
|
rec=$(party_rec tampered)
|
|
party_dir="$PARTY_SOCKET_DIR/party-$USER:tampered.d"
|
|
[ -d "$party_dir" ]
|
|
|
|
sentinel="$PARTY_TMP/sentinel"
|
|
mkdir -p "$sentinel"
|
|
: > "$sentinel/canary"
|
|
|
|
# Forge SOCKET= to point inside the sentinel.
|
|
awk -v new="$sentinel/sock" 'BEGIN{FS=OFS="="} $1=="SOCKET"{$2=new}1' \
|
|
"$rec" > "$rec.new" && mv "$rec.new" "$rec"
|
|
|
|
run "$PARTY_BIN" close tampered
|
|
[ "$status" -eq 0 ]
|
|
|
|
# Sentinel survives: cmd_close used the derived path, not the forged
|
|
# SOCKET=. This is the security property.
|
|
[ -d "$sentinel" ]
|
|
[ -f "$sentinel/canary" ]
|
|
# The real per-party dir is cleaned up regardless.
|
|
[ ! -d "$party_dir" ]
|
|
}
|
|
|
|
@test "close refuses to operate on a symlinked per-party dir" {
|
|
# With a sticky parent (e.g. /tmp), another local user can pre-create
|
|
# party-$USER:<name>.d as a symlink before we ever host. cmd_close
|
|
# must lstat-and-refuse before reading the (potentially forged) roster
|
|
# or invoking tmux against a symlinked socket path.
|
|
victim="$PARTY_TMP/victim"
|
|
mkdir -p "$victim"
|
|
: > "$victim/sock"
|
|
cat > "$victim/roster" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=hijack
|
|
SOCKET=$victim/sock
|
|
SERVER_PID=$FAKE_SERVER_PID
|
|
GROUP=$TMUX_PARTY_GROUP
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
|
|
party_dir="$PARTY_SOCKET_DIR/party-$USER:hijack.d"
|
|
ln -s "$victim" "$party_dir"
|
|
|
|
run "$PARTY_BIN" close hijack
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"symlink"* ]]
|
|
# tmux must not have been touched: the recorder log has nothing for
|
|
# this party.
|
|
! grep -q "kill-server" "$TMUX_LOG"
|
|
# Symlink and victim contents both intact — we did not chase the
|
|
# symlink to delete the attacker's tree.
|
|
[ -L "$party_dir" ]
|
|
[ -d "$victim" ]
|
|
[ -f "$victim/roster" ]
|
|
}
|
|
|
|
@test "host warns when PARTY_SOCKET_DIR is not group/world readable" {
|
|
# The sandbox parent is mode 0700 — trustworthy but undiscoverable to
|
|
# invitees. cmd_host should accept it (validator is happy) but emit
|
|
# a warning so the host notices before sending invites.
|
|
run "$PARTY_BIN" host noreach
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"not readable"* ]]
|
|
[[ "$output" == *"discover"* ]]
|
|
"$PARTY_BIN" close noreach
|
|
}
|
|
|
|
@test "resolve_authoritative_party skips symlinked party dirs but roster_list does not" {
|
|
load_party_lib
|
|
ensure_party_dir "$USER" real
|
|
rec_real="$PARTY_SOCKET_DIR/party-$USER:real.d/roster"
|
|
cat >"$rec_real" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=real
|
|
SOCKET=$PARTY_SOCKET_DIR/party-$USER:real.d/sock
|
|
SERVER_PID=$$
|
|
GROUP=${TMUX_PARTY_GROUP:-party}
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$rec_real"
|
|
|
|
# Forge a symlink-shaped fake dir pointing at attacker-controlled content.
|
|
victim="$BATS_TEST_TMPDIR/victim"
|
|
mkdir -p "$victim"
|
|
cat >"$victim/roster" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=fake
|
|
SOCKET=/tmp/attacker.sock
|
|
SERVER_PID=$$
|
|
GROUP=${TMUX_PARTY_GROUP:-party}
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
ln -s "$victim" "$PARTY_SOCKET_DIR/party-$USER:fake.d"
|
|
|
|
# roster_list is the cross-user discovery primitive; it must surface
|
|
# both entries so guests can find host-foreign parties.
|
|
out=$(roster_list)
|
|
[[ "$out" == *"party-$USER:real.d/roster"* ]]
|
|
[[ "$out" == *"party-$USER:fake.d/roster"* ]]
|
|
|
|
# resolve_authoritative_party is the host-only walker; without its
|
|
# symlink gate, two candidates would match HOST_USER=$USER and the
|
|
# forged one could redirect host-only commands. With the gate, only
|
|
# the real entry resolves.
|
|
resolve_authoritative_party ""
|
|
[ "$RR_PARTY_NAME" = "real" ]
|
|
}
|
|
|
|
@test "no-arg cmd_close ignores forged symlinked party dirs" {
|
|
# Set up one real party dir with a roster that looks host-owned.
|
|
ensure_party_dir "$USER" real
|
|
rec_real="$PARTY_SOCKET_DIR/party-$USER:real.d/roster"
|
|
cat >"$rec_real" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=real
|
|
SOCKET=$PARTY_SOCKET_DIR/party-$USER:real.d/sock
|
|
SERVER_PID=$FAKE_SERVER_PID
|
|
GROUP=$TMUX_PARTY_GROUP
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$rec_real"
|
|
# Create a placeholder socket so apply_party_perms_file in close doesn't
|
|
# choke; close runs kill-server then rm -rf, so just a file is enough.
|
|
: > "$PARTY_SOCKET_DIR/party-$USER:real.d/sock"
|
|
|
|
# Forge a symlinked fake dir: attacker plants party-$USER:fake.d -> victim.
|
|
victim="$BATS_TEST_TMPDIR/victim"
|
|
mkdir -p "$victim"
|
|
cat >"$victim/roster" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=fake
|
|
SOCKET=$victim/sock
|
|
SERVER_PID=$FAKE_SERVER_PID
|
|
GROUP=$TMUX_PARTY_GROUP
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
ln -s "$victim" "$PARTY_SOCKET_DIR/party-$USER:fake.d"
|
|
|
|
# no-arg close: should find exactly one candidate (real), close it, exit 0.
|
|
# If the forged dir were not filtered, it would see two candidates and
|
|
# exit 1 with "you host multiple parties".
|
|
run "$PARTY_BIN" close
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"real"* ]]
|
|
|
|
# The real party dir must be gone.
|
|
[ ! -d "$PARTY_SOCKET_DIR/party-$USER:real.d" ]
|
|
|
|
# kill-server must not have been invoked against any path under victim.
|
|
! grep -q "$victim" "$TMUX_LOG"
|
|
}
|
|
|
|
@test "roster_read derives RR_SOCKET from dir; ignores file SOCKET= field" {
|
|
load_party_lib
|
|
|
|
ensure_party_dir "$USER" derived
|
|
rec="$PARTY_SOCKET_DIR/party-$USER:derived.d/roster"
|
|
cat >"$rec" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=derived
|
|
SOCKET=/tmp/decoy-sock-$$
|
|
SERVER_PID=$$
|
|
GROUP=${TMUX_PARTY_GROUP:-party}
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$rec"
|
|
|
|
roster_read "$rec"
|
|
# RR_SOCKET is the co-located sock, not the forged path in the file.
|
|
[ "$RR_SOCKET" = "$PARTY_SOCKET_DIR/party-$USER:derived.d/sock" ]
|
|
}
|
|
|
|
@test "roster_read derives RR_HOST_USER and RR_PARTY_NAME from basename; ignores file fields" {
|
|
load_party_lib
|
|
|
|
ensure_party_dir "$USER" bona-fide
|
|
rec="$PARTY_SOCKET_DIR/party-$USER:bona-fide.d/roster"
|
|
# File claims wrong HOST_USER and PARTY_NAME; basename is authoritative.
|
|
cat >"$rec" <<EOF
|
|
HOST_USER=lies
|
|
PARTY_NAME=alsolies
|
|
SOCKET=/wrong/sock
|
|
SERVER_PID=$$
|
|
GROUP=${TMUX_PARTY_GROUP:-party}
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$rec"
|
|
|
|
roster_read "$rec"
|
|
[ "$RR_HOST_USER" = "$USER" ]
|
|
[ "$RR_PARTY_NAME" = "bona-fide" ]
|
|
}
|
|
|
|
@test "roster_read rejects roster whose sock is a symlink" {
|
|
load_party_lib
|
|
|
|
ensure_party_dir "$USER" symtrap
|
|
d="$PARTY_SOCKET_DIR/party-$USER:symtrap.d"
|
|
rm -f "$d/sock"
|
|
|
|
victim="$BATS_TEST_TMPDIR/victim_sock"
|
|
: > "$victim"
|
|
ln -s "$victim" "$d/sock"
|
|
|
|
rec="$d/roster"
|
|
cat >"$rec" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=symtrap
|
|
SOCKET=$d/sock
|
|
SERVER_PID=$$
|
|
GROUP=${TMUX_PARTY_GROUP:-party}
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$rec"
|
|
|
|
! roster_read "$rec"
|
|
}
|
|
|
|
@test "roster_read rejects dir whose owner does not match basename HOST_USER" {
|
|
[ "$USER" = "root" ] && skip "running as root: -user root would trivially match"
|
|
load_party_lib
|
|
|
|
# Basename claims the dir is hosted by 'root', but mkdir runs as us.
|
|
fake="$PARTY_SOCKET_DIR/party-root:foo.d"
|
|
mkdir -p "$fake"
|
|
chmod 0750 "$fake"
|
|
: > "$fake/sock"
|
|
|
|
rec="$fake/roster"
|
|
cat >"$rec" <<EOF
|
|
HOST_USER=root
|
|
PARTY_NAME=foo
|
|
SOCKET=$fake/sock
|
|
SERVER_PID=$$
|
|
GROUP=${TMUX_PARTY_GROUP:-party}
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$rec"
|
|
|
|
# Owner of $fake is $USER, but basename says root → reject.
|
|
! roster_read "$rec"
|
|
}
|
|
|
|
@test "cmd_leave ignores forged roster pointing at a different socket" {
|
|
# Set up a legitimate party that the "victim" (us) is joined to.
|
|
# The stub answers has-session with success so _disconnect_user fires.
|
|
legit_dir="$PARTY_SOCKET_DIR/party-$USER:legit.d"
|
|
mkdir -p "$legit_dir" && chmod 0750 "$legit_dir"
|
|
: > "$legit_dir/sock"
|
|
cat >"$legit_dir/roster" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=legit
|
|
SOCKET=$legit_dir/sock
|
|
SERVER_PID=$FAKE_SERVER_PID
|
|
GROUP=$TMUX_PARTY_GROUP
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$legit_dir/roster"
|
|
|
|
# Mallory plants a forged party dir whose roster points at a victim socket.
|
|
victim_sock="$BATS_TEST_TMPDIR/victim-sock"
|
|
trap_dir="$PARTY_SOCKET_DIR/party-mallory:trap.d"
|
|
mkdir -p "$trap_dir" && chmod 0750 "$trap_dir"
|
|
cat >"$trap_dir/roster" <<EOF
|
|
HOST_USER=mallory
|
|
PARTY_NAME=trap
|
|
SOCKET=$victim_sock
|
|
SERVER_PID=$FAKE_SERVER_PID
|
|
GROUP=$TMUX_PARTY_GROUP
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$trap_dir/roster"
|
|
|
|
# Wire up the stub: has-session returns 0 for the legit party so
|
|
# cmd_leave considers us attached there; list-clients returns our USER.
|
|
cat > "$PARTY_TMP/tmux-stub" <<STUB
|
|
#!/bin/sh
|
|
printf '%s\n' "\$*" >> "\$TMUX_LOG"
|
|
sock=
|
|
prev=
|
|
for a in "\$@"; do
|
|
if [ "\$prev" = "-S" ]; then sock="\$a"; fi
|
|
prev="\$a"
|
|
done
|
|
case "\$*" in
|
|
*"new-session -d "*)
|
|
[ -n "\$sock" ] && : > "\$sock"
|
|
;;
|
|
*"display-message -p "*)
|
|
echo "\$FAKE_SERVER_PID"
|
|
;;
|
|
*"has-session"*)
|
|
exit 0
|
|
;;
|
|
*"list-clients"*)
|
|
printf '#{client_user}\n' | sed "s/.*/$USER/"
|
|
;;
|
|
esac
|
|
exit 0
|
|
STUB
|
|
chmod +x "$PARTY_TMP/tmux-stub"
|
|
|
|
run "$PARTY_BIN" leave
|
|
|
|
# cmd_leave must not have contacted the victim socket.
|
|
! grep -qF "$victim_sock" "$TMUX_LOG"
|
|
|
|
# It must have operated on the legit party socket instead.
|
|
grep -q "$legit_dir/sock" "$TMUX_LOG"
|
|
}
|
|
|
|
@test "host rolls back on partial setup failure (no orphan dir or server)" {
|
|
# Without rollback, a failure between new-session and roster_write
|
|
# leaves a live tmux server with no record. The trap inside cmd_host
|
|
# must kill-server and rm -rf the per-party dir before exiting.
|
|
party_dir="$PARTY_SOCKET_DIR/party-$USER:rollback.d"
|
|
|
|
FAIL_ON_SETHOOK=1 run "$PARTY_BIN" host rollback
|
|
[ "$status" -ne 0 ]
|
|
|
|
# Trap fired: kill-server invoked against the derived socket.
|
|
grep -q "$party_dir/sock kill-server" "$TMUX_LOG"
|
|
|
|
# Per-party dir is gone — no leftover roster, no leftover sock.
|
|
[ ! -e "$party_dir" ]
|
|
}
|
|
|
|
@test "host refuses leftover per-party dir without a roster" {
|
|
# Simulate a prior incarnation that left a per-party dir behind with
|
|
# no roster (the rollback trap couldn't fire — SIGKILL or panic).
|
|
# cmd_host's mkdir-as-lock acquisition will fail with EEXIST. We
|
|
# deliberately do NOT auto-clean: the same code path triggers on a
|
|
# concurrent in-flight host attempt for the same name, and racing
|
|
# rm -rf calls would wipe each other's fresh dirs.
|
|
# Recovery is a manual `rm -rf` per the error message.
|
|
party_dir="$PARTY_SOCKET_DIR/party-$USER:resurrect.d"
|
|
mkdir -p "$party_dir"
|
|
: > "$party_dir/sock"
|
|
|
|
run "$PARTY_BIN" host resurrect
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"already exists"* ]]
|
|
[[ "$output" == *"rm -rf $party_dir"* ]]
|
|
|
|
# The leftover dir is untouched — proves we did not race-clean.
|
|
[ -d "$party_dir" ]
|
|
[ -e "$party_dir/sock" ]
|
|
|
|
# And we never spoke to tmux at all on the refusal path.
|
|
! grep -q "kill-server" "$TMUX_LOG"
|
|
! grep -q "new-session" "$TMUX_LOG"
|
|
}
|
|
|
|
@test "roster_read rejects per-party dir with other-readable mode bits" {
|
|
# cmd_host writes the per-party dir as 0750 (no other-bits). A non-member
|
|
# outside TMUX_PARTY_GROUP cannot traverse such a dir. Anything appearing
|
|
# in roster_list with o+r/o+w/o+x is a spoof attempt: a local user planted
|
|
# a world-readable party-attacker:foo.d under a sticky parent (e.g. /tmp)
|
|
# to inject a fake party into someone else's `party list`. roster_read
|
|
# must reject before tmux is contacted.
|
|
load_party_lib
|
|
ensure_party_dir "$USER" worldread
|
|
d="$PARTY_SOCKET_DIR/party-$USER:worldread.d"
|
|
chmod 0755 "$d"
|
|
rec="$d/roster"
|
|
cat >"$rec" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=worldread
|
|
SOCKET=$d/sock
|
|
SERVER_PID=$$
|
|
GROUP=${TMUX_PARTY_GROUP:-party}
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$rec"
|
|
! roster_read "$rec"
|
|
}
|
|
|
|
@test "roster_read rejects basename whose party name fails validate_party_name" {
|
|
# The basename glob (party-*:*.d) is permissive — names like '.' (basename
|
|
# party-USER:..d) match it. cmd_host's validate_party_name rejects '.' as
|
|
# a reserved name; roster_read runs the same check on read so a forged
|
|
# dir whose basename derives an illegal name can't pass.
|
|
load_party_lib
|
|
d="$PARTY_SOCKET_DIR/party-$USER:..d"
|
|
mkdir -p "$d"
|
|
chmod 0750 "$d"
|
|
: > "$d/sock"
|
|
rec="$d/roster"
|
|
cat >"$rec" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=.
|
|
SOCKET=$d/sock
|
|
SERVER_PID=$$
|
|
GROUP=${TMUX_PARTY_GROUP:-party}
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$rec"
|
|
! roster_read "$rec"
|
|
}
|
|
|
|
@test "cmd_list skips parties whose tmux list-clients fails" {
|
|
# SERVER_PID liveness alone doesn't prove a party is real — init's PID 1
|
|
# always passes, and a non-tmux daemon (or `nc -lU`) can hold a real
|
|
# AF_UNIX socket. cmd_list must run list-clients and treat non-zero as
|
|
# "not a real party," skipping the entry rather than reporting it with
|
|
# 0 attendees.
|
|
legit_dir="$PARTY_SOCKET_DIR/party-$USER:legit.d"
|
|
mkdir -p "$legit_dir" && chmod 0750 "$legit_dir"
|
|
: > "$legit_dir/sock"
|
|
cat >"$legit_dir/roster" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=legit
|
|
SOCKET=$legit_dir/sock
|
|
SERVER_PID=$FAKE_SERVER_PID
|
|
GROUP=$TMUX_PARTY_GROUP
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$legit_dir/roster"
|
|
|
|
fake_dir="$PARTY_SOCKET_DIR/party-$USER:imposter.d"
|
|
mkdir -p "$fake_dir" && chmod 0750 "$fake_dir"
|
|
: > "$fake_dir/sock"
|
|
cat >"$fake_dir/roster" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=imposter
|
|
SOCKET=$fake_dir/sock
|
|
SERVER_PID=$FAKE_SERVER_PID
|
|
GROUP=$TMUX_PARTY_GROUP
|
|
CREATED=2026-04-29T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$fake_dir/roster"
|
|
|
|
# Stub: list-clients exits 1 against the imposter sock, 0 (empty list)
|
|
# against the legit one. Anything else is a no-op so the rest of cmd_list
|
|
# behaves normally.
|
|
cat > "$PARTY_TMUX" <<STUB
|
|
#!/bin/sh
|
|
printf '%s\n' "\$*" >> "\$TMUX_LOG"
|
|
case "\$*" in
|
|
*list-clients*)
|
|
case "\$*" in
|
|
*party-$USER:imposter.d*) exit 1 ;;
|
|
esac
|
|
;;
|
|
esac
|
|
exit 0
|
|
STUB
|
|
chmod +x "$PARTY_TMUX"
|
|
|
|
run "$PARTY_BIN" list
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"legit"* ]]
|
|
[[ "$output" != *"imposter"* ]]
|
|
}
|
|
|
|
@test "invite warns when invitee is not in the party's group" {
|
|
# Need a real OS user that's not in TMUX_PARTY_GROUP. 'nobody' exists
|
|
# on Linux, FreeBSD, OpenBSD, macOS, and illumos; on the matrix it
|
|
# is never a member of the host's primary group.
|
|
id -u nobody >/dev/null 2>&1 || skip "no 'nobody' user on this host"
|
|
if id -nG nobody 2>/dev/null | tr ' ' '\n' | grep -qx "$TMUX_PARTY_GROUP"; then
|
|
skip "nobody is unexpectedly a member of $TMUX_PARTY_GROUP on this host"
|
|
fi
|
|
|
|
"$PARTY_BIN" host inv >/dev/null
|
|
|
|
run "$PARTY_BIN" invite nobody --party inv
|
|
[ "$status" -eq 0 ]
|
|
# Warning surfaces with the right group name and remediation.
|
|
[[ "$output" == *"not in group '$TMUX_PARTY_GROUP'"* ]]
|
|
[[ "$output" == *"usermod -aG $TMUX_PARTY_GROUP nobody"* ]]
|
|
# Warn-and-proceed: the auth-list mutation still happened, so a later
|
|
# group grant doesn't require a re-invite.
|
|
[[ "$output" == *"Invited nobody"* ]]
|
|
grep -q "server-access -aw nobody" "$TMUX_LOG"
|
|
}
|
|
|
|
@test "find_party_by_name skips parties whose tmux list-clients fails" {
|
|
# Parity with test 76 ('cmd_list skips parties...'), but for the join
|
|
# resolver. A roster can have a live SERVER_PID (PID reuse, init's pid 1)
|
|
# and an [ -S ]-passing socket (planted via `nc -lU`) yet not be a real
|
|
# tmux server. is_party_alive must probe list-clients on every resolver,
|
|
# not just cmd_list — otherwise `party join
|
|
# imposter` would resolve to the imposter and try to attach.
|
|
legit_dir="$PARTY_SOCKET_DIR/party-$USER:legit2.d"
|
|
mkdir -p "$legit_dir" && chmod 0750 "$legit_dir"
|
|
: > "$legit_dir/sock"
|
|
cat >"$legit_dir/roster" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=legit2
|
|
SOCKET=$legit_dir/sock
|
|
SERVER_PID=$FAKE_SERVER_PID
|
|
GROUP=$TMUX_PARTY_GROUP
|
|
CREATED=2026-04-30T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$legit_dir/roster"
|
|
|
|
fake_dir="$PARTY_SOCKET_DIR/party-$USER:imposter2.d"
|
|
mkdir -p "$fake_dir" && chmod 0750 "$fake_dir"
|
|
: > "$fake_dir/sock"
|
|
cat >"$fake_dir/roster" <<EOF
|
|
HOST_USER=$USER
|
|
PARTY_NAME=imposter2
|
|
SOCKET=$fake_dir/sock
|
|
SERVER_PID=$FAKE_SERVER_PID
|
|
GROUP=$TMUX_PARTY_GROUP
|
|
CREATED=2026-04-30T00:00:00Z
|
|
EOF
|
|
chmod 0640 "$fake_dir/roster"
|
|
|
|
cat > "$PARTY_TMUX" <<STUB
|
|
#!/bin/sh
|
|
printf '%s\n' "\$*" >> "\$TMUX_LOG"
|
|
case "\$*" in
|
|
*list-clients*)
|
|
case "\$*" in
|
|
*party-$USER:imposter2.d*) exit 1 ;;
|
|
esac
|
|
;;
|
|
esac
|
|
exit 0
|
|
STUB
|
|
chmod +x "$PARTY_TMUX"
|
|
|
|
# cmd_join refuses to attach when already inside tmux; force-clear so
|
|
# the resolver runs to completion without the early bail.
|
|
unset TMUX
|
|
|
|
run "$PARTY_BIN" join imposter2
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"not found"* ]]
|
|
}
|
|
|
|
@test "host refuses to start when tmux lacks server-access (auth gate missing)" {
|
|
# cmd_host MUST require server-access at runtime, not just in the
|
|
# README. tmux < 3.3 doesn't support the command, and silently
|
|
# swallowing its failure (the prior behavior) would degrade the auth
|
|
# gate to nothing — group members with FS access could attach without
|
|
# being invited.
|
|
cat > "$PARTY_TMUX" <<'STUB'
|
|
#!/bin/sh
|
|
printf '%s\n' "$*" >> "$TMUX_LOG"
|
|
sock=
|
|
prev=
|
|
for a in "$@"; do
|
|
if [ "$prev" = "-S" ]; then sock="$a"; fi
|
|
prev="$a"
|
|
done
|
|
case "$*" in
|
|
*"new-session -d "*)
|
|
[ -n "$sock" ] && : > "$sock"
|
|
;;
|
|
*"display-message -p "*)
|
|
echo "$FAKE_SERVER_PID"
|
|
;;
|
|
*"server-access "*)
|
|
# Simulate tmux < 3.3 — command unknown.
|
|
echo "unknown command: server-access" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
exit 0
|
|
STUB
|
|
chmod +x "$PARTY_TMUX"
|
|
|
|
run "$PARTY_BIN" host noauth
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"server-access"* ]]
|
|
[[ "$output" == *"3.3"* ]]
|
|
|
|
# Rollback: trap fired, party dir is gone, no roster.
|
|
party_dir="$PARTY_SOCKET_DIR/party-$USER:noauth.d"
|
|
[ ! -d "$party_dir" ]
|
|
|
|
# And we never widened the socket: chmod g+rw runs only after the
|
|
# capability probe passes. apply_party_perms_file lives on the file
|
|
# itself, so confirm the cmd stream stopped before that path.
|
|
! grep -q "server-access -aw $USER" "$TMUX_LOG" || \
|
|
fail "should not have attempted -aw before capability check"
|
|
}
|