#!/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 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" < "$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:.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" <"$rec_real" <"$victim/roster" <"$rec_real" < "$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" <"$rec" <"$rec" < "$victim" ln -s "$victim" "$d/sock" rec="$d/roster" cat >"$rec" < "$fake/sock" rec="$fake/roster" cat >"$rec" < "$legit_dir/sock" cat >"$legit_dir/roster" <"$trap_dir/roster" < "$PARTY_TMP/tmux-stub" <> "\$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" < "$d/sock" rec="$d/roster" cat >"$rec" < "$legit_dir/sock" cat >"$legit_dir/roster" < "$fake_dir/sock" cat >"$fake_dir/roster" < "$PARTY_TMUX" <> "\$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" < "$fake_dir/sock" cat >"$fake_dir/roster" < "$PARTY_TMUX" <> "\$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" }