Routing log_notify through .party-notify had turned the announcement into 'started logging the party'. The helper takes an optional object phrase, defaulting to 'the party' for the join/leave hooks, and log_notify passes 'this party'. Both wordings are now pinned by stub tests, which had no coverage of the broadcast text before.
899 lines
28 KiB
Bash
899 lines
28 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 party_conn_state 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
|
|
# party_conn_state 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"
|
|
# Optional injection point for the signal test: TERM the calling
|
|
# party process while it waits on us, mid-setup.
|
|
[ -n "${SIGNAL_ON_NEWSESSION:-}" ] && kill -TERM "$PPID"
|
|
;;
|
|
*"list-clients -F #{client_name}"*)
|
|
# Optional injection point for the broadcast tests: pretend one
|
|
# client is attached so the notify fan-out has someone to message.
|
|
[ -n "${FAKE_CLIENT:-}" ] && echo "$FAKE_CLIENT"
|
|
;;
|
|
*"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 interrupted by a signal rolls back and stops" {
|
|
# A signal trap body that does not exit lets the shell resume where
|
|
# it left off: cmd_host would clean up, then carry on configuring a
|
|
# party dir it just removed. The signal must end the run.
|
|
party_dir="$PARTY_SOCKET_DIR/party-$USER:sigterm.d"
|
|
|
|
SIGNAL_ON_NEWSESSION=1 run "$PARTY_BIN" host sigterm
|
|
[ "$status" -eq 143 ]
|
|
|
|
[ ! -e "$party_dir" ]
|
|
# Nothing but the rollback's kill-server follows new-session.
|
|
[ "$(sed -n '/new-session -d /,$p' "$TMUX_LOG" | sed 1d)" \
|
|
= "-S $party_dir/sock kill-server" ]
|
|
}
|
|
|
|
@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 `party clean` 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" == *"party clean"* ]]
|
|
|
|
# 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. party_conn_state 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"
|
|
}
|
|
|
|
@test "moderation accepts --party to disambiguate multiple hosted parties" {
|
|
"$PARTY_BIN" host alpha
|
|
"$PARTY_BIN" host bravo
|
|
|
|
# Without --party: ambiguous, must refuse and say so.
|
|
run "$PARTY_BIN" kick nobody
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"multiple parties"* ]]
|
|
|
|
# With --party: resolves, and the server-access -d lands on bravo's
|
|
# socket specifically.
|
|
: > "$TMUX_LOG"
|
|
run "$PARTY_BIN" kick nobody --party bravo
|
|
[ "$status" -eq 0 ]
|
|
grep "server-access -d nobody" "$TMUX_LOG" | grep -q "party-$USER:bravo.d/sock"
|
|
|
|
: > "$TMUX_LOG"
|
|
run "$PARTY_BIN" voice nobody --party alpha
|
|
[ "$status" -eq 0 ]
|
|
grep "server-access -w nobody" "$TMUX_LOG" | grep -q "party-$USER:alpha.d/sock"
|
|
|
|
run "$PARTY_BIN" mute nobody --party alpha
|
|
[ "$status" -eq 0 ]
|
|
run "$PARTY_BIN" detach nobody --party alpha
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "list shows the group tag only when visible parties differ on it" {
|
|
"$PARTY_BIN" host solo
|
|
run "$PARTY_BIN" list
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"solo"* ]]
|
|
[[ "$output" != *"[group="* ]]
|
|
|
|
# A second party gated on another of the caller's groups.
|
|
other=$(id -nG | tr ' ' '\n' | grep -vx "$TMUX_PARTY_GROUP" | head -1)
|
|
[ -n "$other" ] || skip "caller is in a single group"
|
|
"$PARTY_BIN" host duo --group "$other"
|
|
run "$PARTY_BIN" list
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"solo"*"[group=$TMUX_PARTY_GROUP]"* ]]
|
|
[[ "$output" == *"duo"*"[group=$other]"* ]]
|
|
}
|
|
|
|
@test "log start and stop are announced to attached clients" {
|
|
"$PARTY_BIN" host taped
|
|
export FAKE_CLIENT=/dev/pts/99
|
|
|
|
run "$PARTY_BIN" log
|
|
[ "$status" -eq 0 ]
|
|
grep -q "display-message -c /dev/pts/99 $USER started logging this party$" "$TMUX_LOG"
|
|
|
|
run "$PARTY_BIN" log --stop
|
|
[ "$status" -eq 0 ]
|
|
grep -q "display-message -c /dev/pts/99 $USER stopped logging this party$" "$TMUX_LOG"
|
|
}
|
|
|
|
@test "join/leave broadcast keeps its default wording" {
|
|
"$PARTY_BIN" host greet
|
|
party_dir="$PARTY_SOCKET_DIR/party-$USER:greet.d"
|
|
FAKE_CLIENT=/dev/pts/99 "$party_dir/.party-notify" "$party_dir/sock" alice joined
|
|
grep -q "display-message -c /dev/pts/99 alice joined the party$" "$TMUX_LOG"
|
|
}
|