commit 6be0ac1877896aa66b20ea316f221e41927de953 Author: veg Date: Mon Jun 1 10:45:32 2026 +0000 Initial pre-release 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42663a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Test scratch +tests/.tmp/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f2432fe --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +PREFIX = +DESTDIR = +BINDIR = +MANDIR = + +INSTALL = install + +# Default install prefix follows the host OS convention (override on the +# command line: `make PREFIX=...`): +# illumos, Solaris (uname -s = SunOS): /opt/party +# Linux, *BSD, macOS, everywhere else: /usr/local +# POSIX make has no ifeq or $(shell), so the OS dispatch lives in the +# install/uninstall recipes themselves. BINDIR and MANDIR remain +# independently overridable. Plain `=` rather than `?=` keeps illumos +# /usr/bin/make happy (it predates POSIX-2008's conditional assignment). +# +# Beyond copying files, `make install` also wires the install bindir into +# the system PATH so `party` and `man party` work from a fresh login shell +# without -M or rc-file edits. The wiring is done by tools/post-install.sh +# and is OS-aware: on illumos /opt/party is appended to /etc/default/login; +# on Linux/BSD/macOS at /usr/local nothing needs doing; for non-standard +# prefixes elsewhere the script prints the export lines to add manually. +# `make uninstall` reverses the wiring symmetrically. Both steps are +# skipped when DESTDIR is set (package staging context). + +all: + @echo "party is a single shell script; nothing to build." + @echo "Run 'make install' (PREFIX auto-detects from uname -s)." + +install: + @prefix='$(PREFIX)'; \ + [ -n "$$prefix" ] || case `uname -s` in \ + SunOS) prefix=/opt/party ;; \ + *) prefix=/usr/local ;; \ + esac; \ + bindir='$(BINDIR)'; [ -n "$$bindir" ] || bindir="$$prefix/bin"; \ + mandir='$(MANDIR)'; [ -n "$$mandir" ] || mandir="$$prefix/share/man/man1"; \ + echo "Installing to $$prefix (bin=$$bindir, man=$$mandir)"; \ + $(INSTALL) -d "$(DESTDIR)$$bindir" "$(DESTDIR)$$mandir" && \ + $(INSTALL) -m 0755 party "$(DESTDIR)$$bindir/party" && \ + $(INSTALL) -m 0644 party.1 "$(DESTDIR)$$mandir/party.1" && \ + { [ -n "$(DESTDIR)" ] || sh tools/post-install.sh install "$$prefix"; } + +uninstall: + @prefix='$(PREFIX)'; \ + [ -n "$$prefix" ] || case `uname -s` in \ + SunOS) prefix=/opt/party ;; \ + *) prefix=/usr/local ;; \ + esac; \ + bindir='$(BINDIR)'; [ -n "$$bindir" ] || bindir="$$prefix/bin"; \ + mandir='$(MANDIR)'; [ -n "$$mandir" ] || mandir="$$prefix/share/man/man1"; \ + rm -f "$(DESTDIR)$$bindir/party" "$(DESTDIR)$$mandir/party.1"; \ + [ -n "$(DESTDIR)" ] || sh tools/post-install.sh uninstall "$$prefix" + +check: + sh -n party + @if command -v bats >/dev/null 2>&1; then \ + bats tests; \ + else \ + echo "bats not installed; skipping tests."; \ + fi + +# Static analysis. Deliberately a local-only target rather than a bats +# test: shellcheck reads bytes, not the OS, so running it across the +# remote test matrix would be redundant. SC3065/SC3067 flag `-k`, `-O`, +# and `-L` as non-strict-POSIX, but every /bin/sh in our matrix (dash, +# bash, mksh, ksh) honors them and rewriting through stat would be a +# portability step backward. +lint: + @if command -v shellcheck >/dev/null 2>&1; then \ + shellcheck -s sh -e SC3065,SC3067 party; \ + else \ + echo "shellcheck not installed; install it locally to lint."; \ + exit 1; \ + fi + +.PHONY: all install uninstall check lint diff --git a/README.md b/README.md new file mode 100644 index 0000000..f27ccdd --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# tmux-party + +Share a live tmux session with your buddies on a wide range of UNICES! + +`tmux-party` ships `party`, a POSIX shell utility. One person hosts a party, and others join the fun! Wanna pair-program, mentor, debug an outage together, or demo a workflow? `ssh` in, `party join` & off you go! + +Built for small, mutually trusted groups: a hacklab, a tech team, a circle of friends, not strangers across the internet. Inspired by epic sessions running GNU's `screen -x` way back when, now on BSD's `tmux` modern codebase! + +## Requirements + +- POSIX shell (`/bin/sh`) +- `tmux` ≥ 3.3 (for `server-access`), running when you invoke the script +- A shared system group (default name `party`, override via `TMUX_PARTY_GROUP`). Every host and guest must be a member. +- `write(1)`, optional: used to ping invited guests. Silently skipped when absent. + +## Install + +```sh +make install # installs to /usr/local +make install PREFIX=$HOME/.local # or any prefix you prefer +``` + +Installs `party` to `$(PREFIX)/bin` and `party.1` to `$(PREFIX)/share/man/man1`; `man party` is the full reference. `make uninstall` removes both. + +On illumos/Solaris the default prefix is `/opt/party` and the install needs root (`pfexec make install`) for the system bindir and `/etc/default/login`. The Makefile is plain POSIX make (works with `/usr/bin/make`, BSD make, GNU make). + +## Quick start + +One-time setup, as root. Create the group and add members: + +```sh +groupadd party +usermod -aG party alice +usermod -aG party bob +# alice and bob log out and back in so the new group takes effect +``` + +Host a party, then invite from inside it: + +```sh +party host standup # spawn a dedicated party tmux server +party invite alice # add alice to the allowlist (read/write) +party invite bob -r # invite bob as a read-only watcher +``` + +Guests join: + +```sh +party list # see what's running on this host +party join standup # attach (auto-attaches if only one party is running) +party leave # detach and clean up +``` + +The `party` group is the partyline: filesystem access to party sockets is shared among members, and `tmux server-access` decides who is actually let into a given session. No shared install directory is needed: each party keeps its own state under its own per-party private directory. + +## How it works + +`party host` spawns a *dedicated* tmux server for the party (separate from your personal tmux) so inviting someone exposes only the party, not your other sessions. Each party gets a private directory at `${PARTY_SOCKET_DIR}/party-${USER}:${PARTY_NAME}.d/`, holding the tmux socket and a join/leave notification helper. Guests never type the path; `party join` reads the roster and handles the attach. + +Two gates protect every party, and both must pass: + +- **Filesystem gate**: the per-party directory is `chgrp`'d to `TMUX_PARTY_GROUP` at mode `0750`; the socket is `g+rw`. Non-members can't traverse in or reach the socket. +- **Auth gate**: `tmux server-access` allowlist, editable only by the host. A user who clears the FS gate but isn't on the allowlist is rejected at the protocol layer. + +Party metadata (host, server pid, group, creation time) lives in a `roster` file beside the socket. It's bookkeeping only, as security-relevant fields (host user, socket path) are re-derived from the directory on every read, never trusted from the file. + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `TMUX_PARTY_GROUP` | `party` | Shared group for socket access. Override to reuse an existing group (`wheel`, `users`, `staff`), or pass `--group ` to `party host`. | +| `PARTY_SOCKET_DIR` | `/tmp` | Where each party's private directory (socket + roster) is created. | +| `PARTY_TMUX` | `tmux` | tmux binary to use. Override if tmux ≥ 3.3 lives at a non-standard path. | + +## Support + +The mechanism is identical everywhere: group ownership and mode bits plus `tmux server-access`. No ACL syscalls involved, so the trust path is one piece of code, not a per-OS matrix. + +| Platform | Status | +|---|---| +| Linux | supported, validated on Debian 13 & Alpine v3.21 | +| FreeBSD UFS or ZFS | supported, validated on FreeBSD 15.0 ZFS | +| illumos LX-branded zones | supported, validated on 4.4 BrandZ linux | +| illumos native, Solaris | supported, validated on omnios-r151058 | +| NetBSD, DragonFly | unconfirmed, should work, tests welcome | +| OpenBSD | supported, validated on OpenBSD 7.8 | +| macOS | supported, validated on Darwin 25.5.0 | + +## Security + +`party` assumes you already know and trust everyone you add to the group; it is not a public access-control system. Both gates above protect each party, and the **auth gate** (`tmux server-access`) is authoritative, with the filesystem gate as defense in depth. + +Three honest caveats, with the full detail in `man party`: + +- On ACL-enabled filesystems (ZFS, HFS+/APFS), inherited ACLs can override the mode bits, so the FS gate is best-effort. The auth gate still holds. +- A party's *existence* is not hidden the way *attaching* is. That confidentiality rides on the FS gate. +- Active guests share one tmux server, where any write-capable invitee is trusted by design. Invite read-only (`-r`) if you do not trust that far. + +## Status + +`party` is pre-release. The on-disk layout (per-party directory, roster format, socket path) may change between commits, with no upgrade migration, so close running parties before pulling. A stale roster pointing at an unreadable socket simply won't be discovered; old tmux servers left running must be killed by hand. + +## Reference + +| Subcommand | Effect | +|---|---| +| `party host [name]` | Spawn a dedicated party tmux server. Only the host is on the allowlist. | +| `party invite alice [-r]` | Add alice to the allowlist. `-r` / `--read-only` invites as a watcher. | +| `party voice alice` | Promote alice to read/write. | +| `party mute alice` | Demote alice to read-only. | +| `party kick alice` | Revoke alice's invite, disconnect her, kill her guest session. | +| `party detach alice` | Disconnect alice; keep her on the allowlist. | +| `party list` | List live parties on this host. | +| `party who [--short]` | Show invited and attached users for the current party. | +| `party status` | Show the caller's own state: hosting, attached, or idle. | +| `party close` | Tear down the party server and its roster entry. Host-only. | +| `party join [name] [--passive]` | Join a party. Auto-attaches when one is running; picker otherwise. `--passive` attaches read-only to the host's view (watcher mode). | +| `party leave` | Detach and clean up the per-guest session. | +| `party role [active\|passive\|switch]` | Flip your clients between guest and host session. No arg prints the current role. | +| `party --help` | Help text. | + +## Credits + +Written by veg and kol3rby in the context of the [UNIX Social Club](https://club.unix.rocks/). Issues and contributions welcome. diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..3c77d2e --- /dev/null +++ b/deploy.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Sync the deployable surface to a remote host for `make install`. Pins the +# include list to the seven items the project ships: Makefile, party, party.1, +# README.md, tools/post-install.sh, and tests/ (so the host can run `make +# check` as a target-OS smoke test). This is the dev-rsync variant. The +# release-tarball variant strips tests/ as well; not implemented here. +# +# Usage: +# ./deploy.sh [:path] [extra rsync args] +# +# Path defaults to `tmux-party-src` (relative to the remote login user's +# home directory) when omitted. +# +# Examples: +# ./deploy.sh user@domain.tld # → :tmux-party-src +# ./deploy.sh user@domain.tld:dev/tmux-party-src # explicit path +# ./deploy.sh user@domain.tld -n # dry-run, default path + +set -eu + +dest="${1:-}" +[ -n "$dest" ] || { + echo "usage: $0 [:path] [extra rsync args]" >&2 + exit 2 +} +shift + +# Default path when only a host (no ":path") is given. Bare-host form +# is the common case for casual sync; an explicit path overrides it. +case "$dest" in + *:*) ;; + *) dest="$dest:tmux-party-src" ;; +esac + +cd "$(dirname "$0")" + +exec rsync -av --delete "$@" \ + --include='/Makefile' \ + --include='/party' \ + --include='/party.1' \ + --include='/README.md' \ + --include='/tools/' \ + --include='/tools/post-install.sh' \ + --include='/tests/' \ + --include='/tests/*.bats' \ + --include='/tests/helpers.bash' \ + --include='/tests/run-remote.sh' \ + --exclude='*' \ + ./ "$dest" diff --git a/party b/party new file mode 100644 index 0000000..07745c0 --- /dev/null +++ b/party @@ -0,0 +1,1324 @@ +#!/bin/sh +# party: share a tmux session with the people you already work with. +# +# Single-file POSIX shell. +# +# Project by veg & kol3rby for the UNIX Social Club + +set -eu + +PARTY_VERSION=0.1.0 + +# Overridable defaults +# ==================== + +# $USER may be unset in stripped environments (cron, systemd unit shells, +# `env -i` invocations). Every cmd_* path references it, so guarantee it +# now via id -un rather than crashing later under set -eu. +: "${USER:=$(id -un)}" +: "${TMUX_PARTY_GROUP:=party}" +: "${PARTY_SOCKET_DIR:=/tmp}" +: "${PARTY_TMUX:=tmux}" + +# Usage +# ===== + +usage() { + cat <<'EOF' +Usage: party [args] + +Hosting: + host [name] [--group ] + Start a party on a dedicated tmux server. + close Tear down the party you host. + +Joining: + join [name] [--passive] Join a party. Without name: auto-pick or numbered prompt. + --passive lands in the host's session (mirrored view). + leave Detach from the party you joined. + role [active|passive|switch] + Flip your clients between guest session and host session. + +Moderation (host only): + invite [-r] Add user to allowlist (read/write; -r for read-only). + voice Promote user to read/write (alias: rw). + mute Demote user to read-only (alias: ro). + kick Revoke invite + disconnect + clean up guest session. + detach Disconnect user; keep them on the allowlist. + +Info: + list List discoverable parties on this host. + who [--short] Show invited and attached users for the active party. + status Show the caller's own party state. + --help This message. + --version Print version. +EOF +} + +# Library mode +# ============ + +# Sourced from tests with __PARTY_LIB_ONLY=1 to expose helpers without +# triggering dispatch. The marker MUST be checked before the dispatch +# block at the bottom of the file. + +# Helpers +# ======= + +# Validate a party name. Accepts [A-Za-z0-9._-]{1,63}. Reject empty, +# paths, shell metas, and overlong names: the name lands in a file +# path component, a tmux session name, and shell command lines. +validate_party_name() { + name="$1" + case "$name" in + '' ) echo "party: name must not be empty" >&2; return 2 ;; + esac + # Length cap: 63 keeps the full /tmp/party-${USER}-${name} comfortably + # below the 108-char sun_path limit for typical USERs. + if [ "${#name}" -gt 63 ]; then + echo "party: name '$name' too long (max 63 chars)" >&2 + return 2 + fi + case "$name" in + *[!A-Za-z0-9._-]* ) + echo "party: name '$name' has invalid characters" >&2 + echo " allowed: letters, digits, '.', '_', '-'" >&2 + return 2 ;; + esac + # Reject ".", "..", and any leading-dot variant we don't want. + case "$name" in + .|.. ) + echo "party: name '$name' is reserved" >&2 + return 2 ;; + esac + return 0 +} + +# UTC ISO 8601, portable across BSD/GNU date. +iso_now() { + date -u '+%Y-%m-%dT%H:%M:%SZ' +} + +# Boolean: does the named OS user exist? +user_exists() { + id -u "$1" >/dev/null 2>&1 +} + +# Boolean: is the named user in TMUX_PARTY_GROUP? +user_in_party_group() { + user="$1" + id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$TMUX_PARTY_GROUP" +} + +# Soft assertion: caller is in TMUX_PARTY_GROUP. Fails loudly with +# remediation instructions when not. Used by every subcommand that +# touches the roster or socket. +require_caller_in_group() { + if ! user_in_party_group "$USER"; then + cat >&2 < ... +roster_write() { + rec="$1"; shift + tmp="${rec}.tmp.$$" + : > "$tmp" + while [ $# -ge 2 ]; do + printf '%s=%s\n' "$1" "$2" >> "$tmp" + shift 2 + done + chmod 0640 "$tmp" 2>/dev/null || true + mv "$tmp" "$rec" +} + +# Read a roster record, exporting RR_ into the calling shell. +# +# Trust model: security-relevant fields (RR_HOST_USER, RR_PARTY_NAME, +# RR_SOCKET) come from the filesystem, the dir basename and the dir's +# owner, never from the file's content. The roster file itself supplies +# advisory metadata (RR_SERVER_PID, RR_GROUP, RR_CREATED) which is fine +# to display but not to act on. +# +# This anchors trust in unforgeable primitives: mkdir(2) and bind(2) set +# the owner to the calling euid, and `chown` requires root. A group +# member who plants a forged dir cannot fake its ownership, cannot +# redirect RR_SOCKET (always `$d/sock`), and cannot forge a sock +# elsewhere (we reject `$d` and `$d/sock` symlinks; we require both +# inodes to be owned by the user named in the basename, which catches +# hardlinks pointing at sibling-party sockets). +# +# What we deliberately don't try to defend against: a group member +# hosting a real party that appears in discovery (that's just hosting), +# and TOCTOU swaps inside an attacker's own dir between roster_read and +# the subsequent tmux call (window is short; damage cap is "any tmux op +# the caller was already authorized to perform"). +# +# That damage cap holds because the swap only reaches discovery paths +# (join/leave/status), which act on the caller's own clients/guest +# session, never on someone else's server. The host-only moderation +# paths (kick/detach/voice/mute) can't be redirected at all: they route +# through resolve_authoritative_party, which gates on [ -O "$d" ], so the +# dir is the caller's own (mode 0750, group r-x, no group write) under a +# sticky parent, a group member can neither replace its sock nor rename +# the dir, so there is no socket to swap out from under the tmux call. +roster_read() { + rec="$1" + [ -f "$rec" ] || return 1 + + d="${rec%/roster}" + [ -L "$d" ] && return 1 + [ -L "$d/sock" ] && return 1 + [ -e "$d/sock" ] || return 1 + + base="${d##*/}" + case "$base" in + party-*:*.d) ;; + *) return 1 ;; + esac + rest="${base#party-}" + rest="${rest%.d}" + RR_HOST_USER="${rest%%:*}" + RR_PARTY_NAME="${rest#*:}" + [ -n "$RR_HOST_USER" ] || return 1 + [ -n "$RR_PARTY_NAME" ] || return 1 + + # Pattern parity with cmd_host. The basename glob (party-*:*.d) is + # permissive, names like '.' (basename party-USER:..d) or 'a b' + # match the glob but validate_party_name rejects them. Run the + # canonical validator so the invariant cmd_host enforces on write + # is also checked on read. + validate_party_name "$RR_PARTY_NAME" 2>/dev/null || return 1 + + # Both the dir and its sock must be owned by the user named in the + # basename. -prune halts find's descent so we examine just the path + # itself. find errors (unknown user, missing path) suppress to no + # output → reject. + [ -n "$(find "$d" -prune -user "$RR_HOST_USER" 2>/dev/null)" ] || return 1 + [ -n "$(find "$d/sock" -prune -user "$RR_HOST_USER" 2>/dev/null)" ] || return 1 + + # Mode hardening for cross-user discovery. cmd_host writes the + # per-party dir as mode 0750, no other-bits set. A non-member + # outside TMUX_PARTY_GROUP cannot read or traverse such a dir, so + # anything surfacing in roster_list with other-readable bits is + # suspect: a local user planted a world-readable + # party-attacker:foo.d under a sticky parent (e.g. /tmp) to inject + # a fake party into our `party list` / `party join` flow. Reject + # so the spoof never reaches a tmux command. POSIX find lacks "/" + # or "+" perm operators (BSD/GNU extensions), so test each + # other-bit alone. + [ -n "$(find "$d" -prune -perm -0001 2>/dev/null)" ] && return 1 # o+x + [ -n "$(find "$d" -prune -perm -0002 2>/dev/null)" ] && return 1 # o+w + [ -n "$(find "$d" -prune -perm -0004 2>/dev/null)" ] && return 1 # o+r + + RR_SOCKET="$d/sock" + RR_SERVER_PID='' RR_GROUP='' RR_CREATED='' + while IFS='=' read -r k v; do + # shellcheck disable=SC2034 # RR_CREATED is part of the public schema + case "$k" in + SERVER_PID) RR_SERVER_PID=$v ;; + GROUP) RR_GROUP=$v ;; + CREATED) RR_CREATED=$v ;; + '' | \#*) ;; + esac + done < "$rec" + return 0 +} + +# List full roster record paths discoverable to the caller. The glob +# walks every per-party private dir under PARTY_SOCKET_DIR; entries +# whose group the caller isn't in fail the inner [ -f ] check (EACCES +# on the dir) and are silently skipped. +roster_list() { + [ -d "$PARTY_SOCKET_DIR" ] || return 0 + # No ownership/symlink gate here: roster_list is the cross-user + # discovery primitive (party list, party join, party leave, status), + # so it must surface dirs hosted by other group members. Host-only + # walkers (resolve_authoritative_party, no-arg cmd_close) apply + # their own owner+symlink filter before trusting HOST_USER. + for d in "$PARTY_SOCKET_DIR"/party-*:*.d; do + [ -d "$d" ] || continue + rec="$d/roster" + [ -f "$rec" ] || continue + printf '%s\n' "$rec" + done +} + + +# Liveness +# -------- + +# Liveness check: the socket is a live tmux server we can speak the tmux +# protocol to. `tmux -S list-clients` exits 0 only against a real tmux +# server bound to that exact socket path, which simultaneously rules out +# stale roster pointers (server gone), AF_UNIX impostors (a same-group +# user planting a hand-rolled listener via `nc -lU`), and PID-reuse +# leftovers, none of those answer the tmux handshake. Stronger than any +# PID check, and it works regardless of who owns the server process. +# +# Earlier revisions also gated on `kill -0 $pid` as a cheap pre-filter. +# That broke cross-user discovery on every non-Linux POSIX target: per +# POSIX, kill(2) signal 0 may return EPERM when the caller lacks +# send-permission, and illumos, all the BSDs, and macOS honor that. +# Linux is the outlier that returns 0 for "exists, even if not +# signalable," and Linux ABI environments (LX-branded zones) inherit +# that behavior, which is why the bug hid in the test matrix until a +# native illumos run surfaced it. The PID arg is kept in the function +# signature for caller compatibility and as a numeric-validation guard, +# but is no longer probed. Local var names are prefixed to avoid +# clobbering caller-side `pid` / `sock` (POSIX sh has no real locals). +is_party_alive() { + _pa_pid="$1" + _pa_sock="$2" + case "$_pa_pid" in + '' | *[!0-9]* ) return 1 ;; + esac + "$PARTY_TMUX" -S "$_pa_sock" list-clients >/dev/null 2>&1 +} + +# FS perms +# -------- +# +# The OS group is the perimeter, tmux's server-access allowlist is the +# per-user filter. Both are universal across the supported platforms +# (Linux, FreeBSD, NetBSD, OpenBSD, illumos, Solaris, macOS), so the +# mechanism is one liner: chgrp + chmod. No ACL syscalls invoked. +# +# ACL-shadowing on filesystems with NFSv4-style extended ACLs (macOS +# HFS+/APFS, FreeBSD/illumos ZFS): inherited ACEs on PARTY_SOCKET_DIR +# can override the mode bits we set here, either widening access (an +# inherited `everyone@:rx` survives our chmod 0750 because mode bits +# don't displace ACEs) or narrowing it (a `deny everyone:` blocks the +# group members our chgrp tried to admit). We deliberately do NOT +# invoke `getfacl` / `getextattr` / `ls -le` to verify effective +# access. Two reasons: +# +# 1. Re-introducing per-platform ACL plumbing would unwind the +# simplification this redesign committed to: one universal +# mechanism, no OS branching in the trust path. +# +# 2. The dual gate keeps the FS layer from being the sole +# authorization check. tmux server-access is the authoritative +# perimeter on every platform; the FS gate is defense in depth. +# An ACL-shadowed FS gate is a *weakening of depth*, not a +# privilege escalation: a user who wins the FS-gate race still +# has to be on the auth-list to attach, and the auth-list is +# only edited by the host. +# +# Treat the FS gate as best-effort discoverability and traversal +# control. If you operate on a filesystem with restrictive inherited +# ACLs that chmod 0750 can't override, document it in your deployment +# notes, server-access still keeps strangers out, but advertised +# group members may need ACL-level access to traverse PARTY_SOCKET_DIR. +# See README §Trust model for the user-facing version of this caveat. +# WONTFIX, by design. + +apply_party_perms_file() { + p="$1" + chgrp "$TMUX_PARTY_GROUP" "$p" + chmod g+rw "$p" +} + +# Refuse to operate when PARTY_SOCKET_DIR is somewhere an attacker could +# rename our per-party directory between mkdir and use. Trustworthy is +# either: sticky (mode +t, like /tmp), or owned by the caller and neither +# group- nor world-writable. Default /tmp passes by virtue of the sticky +# bit. Group-writable parents are rejected because the parent's group +# may differ from TMUX_PARTY_GROUP, in that case its members would be +# outside our trust perimeter yet able to rename or unlink our per-party +# directory. +validate_socket_dir_parent() { + p="$PARTY_SOCKET_DIR" + if [ ! -d "$p" ]; then + echo "party: PARTY_SOCKET_DIR $p does not exist" >&2 + return 1 + fi + if [ -k "$p" ]; then + # Sticky bit only protects entries against non-owners; the parent's + # OWNER can still rename or unlink our per-party dir. Accept sticky + # only if the parent is owned by us or by root. + [ -O "$p" ] && return 0 + # shellcheck disable=SC2012 # ls -ldn is portable; find -printf is not + p_uid=$(ls -ldn "$p" 2>/dev/null | awk '{print $3; exit}') + [ "$p_uid" = 0 ] && return 0 + fi + # Two separate -perm checks: POSIX/BSD find uses `+` for "any of", + # GNU find uses `/`, but both accept `-mode` ("all of these bits set"), + # so we ask the question twice instead of OR-ing in one expression. + if [ -O "$p" ] \ + && [ -z "$(find "$p" -prune -perm -0002 2>/dev/null)" ] \ + && [ -z "$(find "$p" -prune -perm -0020 2>/dev/null)" ]; then + return 0 + fi + cat >&2 </dev/null | tr -d ' ') + [ -n "$rand" ] || rand=$$ + printf 'party-%s\n' "$rand" +} + + +# After spawning the party server, configure status line and hooks. +# All commands run against -S "$sock". Every literal name embedded +# is shell-quoted to survive tmux's parser. +tmux_party_setup_server() { + sock="$1" name="$2" + + # Write a tiny notification helper next to the socket. The leading dot + # keeps it out of typical `ls` listings. The per-party private dir + # hosts at most one socket so a fixed name is safe. + notify_script="${sock%/*}/.party-notify" + cat > "$notify_script" <<'EOF' +#!/bin/sh +# Auto-generated by party. Fan a display-message out to every client +# of the party server. Args: $1 = socket, $2 = client_user, $3 = "joined"|"left" +sock="$1"; user="$2"; verb="$3" +tmux -S "$sock" list-clients -F '#{client_name}' 2>/dev/null \ + | while read -r c; do + tmux -S "$sock" display-message -c "$c" "$user $verb the party" 2>/dev/null || : + done +EOF + chmod 0755 "$notify_script" + # Match the rest of the per-party dir's group ownership so guests' + # debugging (`ls -l` inside a party dir they can traverse) doesn't + # surface this one file as the odd `:other` outlier. The script is + # invoked by tmux run-shell as the host, so guests don't need + # execute on it; setting the group is purely for consistency. + chgrp "$TMUX_PARTY_GROUP" "$notify_script" + + "$PARTY_TMUX" -S "$sock" set-option -s exit-empty on + "$PARTY_TMUX" -S "$sock" set-option -g status-right \ + "party: #(party who --short --socket $sock 2>/dev/null)" + # Join/leave broadcast. Hook bodies fan a display-message out to + # every attached client of THIS server via the notify helper script. + # tmux substitutes #{client_user} before running the shell command. + "$PARTY_TMUX" -S "$sock" set-hook -g client-attached \ + "run-shell '$notify_script $sock #{client_user} joined'" + "$PARTY_TMUX" -S "$sock" set-hook -g client-detached \ + "run-shell '$notify_script $sock #{client_user} left'" +} + +# Find the live roster record for a party named "$name". Searches across +# all hosts. On match, sets RR_*. Returns 0 on found, 1 on not found, +# 2 on ambiguous (same name from multiple hosts). +find_party_by_name() { + target="$1" + set -- + for rec in $(roster_list); do + roster_read "$rec" || continue + is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue + [ "$RR_PARTY_NAME" = "$target" ] && set -- "$@" "$rec" + done + case $# in + 0) return 1 ;; + 1) roster_read "$1"; return 0 ;; + *) echo "party: '$target' is ambiguous (multiple hosts running it)" >&2 + return 2 ;; + esac +} + +# Pick the single live party, or run a numbered prompt. +pick_live_party() { + set -- + for rec in $(roster_list); do + roster_read "$rec" || continue + is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue + set -- "$@" "$rec" + done + case $# in + 0) echo "party: no parties found on this host." >&2; return 1 ;; + 1) roster_read "$1"; return 0 ;; + esac + i=0 + for r in "$@"; do + i=$((i+1)) + roster_read "$r" + printf '%d) %s, %s\n' "$i" "$RR_HOST_USER" "$RR_PARTY_NAME" >&2 + done + printf 'Choose: ' >&2 + read -r pick + case "$pick" in ''|*[!0-9]*) + echo "party: invalid choice" >&2; return 1 ;; + esac + if [ "$pick" -lt 1 ] || [ "$pick" -gt $# ]; then + echo "party: out of range" >&2; return 1 + fi + eval "roster_read \"\$$pick\"" +} + +# Subcommand dispatch +# =================== + +cmd_host() { + name= + group_arg= + while [ $# -gt 0 ]; do + case "$1" in + --group) + [ $# -ge 2 ] || { echo "party host: --group needs an argument" >&2; exit 2; } + group_arg="$2"; shift 2 ;; + --group=*) group_arg="${1#--group=}"; shift ;; + -h|--help) + cat <<'EOF' +Usage: party host [name] [--group ] + name Party name (default: random). + --group Group used to gate this party. The host must be a + member; only members can discover or join. Precedence: + --group flag > $TMUX_PARTY_GROUP env > default 'party'. + Pick any existing group (e.g. 'staff', 'users', 'wheel'); + no install-time setup needed. +EOF + exit 0 ;; + -*) + echo "party host: unknown flag '$1'" >&2; exit 2 ;; + *) + if [ -z "$name" ]; then name="$1"; shift + else echo "party host: unexpected arg '$1'" >&2; exit 2; fi + ;; + esac + done + + # Group precedence: --group flag > $TMUX_PARTY_GROUP env > default + # 'party' (set by the env-defaults block at the top of the file). + # The chosen group becomes the per-party perimeter: chgrp on the + # private dir, on the socket, and on the roster. No shared install + # directory, pick any existing group the host is in. + if [ -n "$group_arg" ]; then + TMUX_PARTY_GROUP="$group_arg" + export TMUX_PARTY_GROUP + fi + + require_caller_in_group + validate_socket_dir_parent || exit 1 + + # A trustworthy parent isn't necessarily a discoverable parent. With + # mode 0700, hosting and inviting succeed but no other user can + # traverse PARTY_SOCKET_DIR to glob for parties, `party join` and + # `party list` will report not-found for everyone but the host. + # Warn so the user notices before sending invites. Octal 0050 = group + # r+x; 0005 = other r+x. Non-empty `find -prune -perm -` means + # those bits are set on the parent. + # + # Known blind spot (WONTFIX for now): we check that *some* group bits + # are set, not that the parent's group matches TMUX_PARTY_GROUP. With + # PARTY_SOCKET_DIR mode 0750 owned by user:wheel and TMUX_PARTY_GROUP=party, + # this branch is skipped (group r+x is set, for wheel members), but + # party-group members who aren't in wheel still can't traverse. The + # mismatch self-announces on the first failed `party join`, and a + # portable name→gid map is awkward in POSIX shell (no getent on macOS, + # stat flags split BSD/Linux). Default PARTY_SOCKET_DIR=/tmp (1777) + # never trips this; only custom dirs with restrictive group perms do. + if [ -z "$(find "$PARTY_SOCKET_DIR" -prune -perm -0050 2>/dev/null)" ] \ + && [ -z "$(find "$PARTY_SOCKET_DIR" -prune -perm -0005 2>/dev/null)" ]; then + cat >&2 <&2 <&2 + exit 1 + fi + if [ -d "$party_dir" ] && [ -f "$rec" ]; then + roster_read "$rec" 2>/dev/null || true + if is_party_alive "${RR_SERVER_PID:-}" "${RR_SOCKET:-}"; then + echo "party: '$name' is already running (pid $RR_SERVER_PID)" >&2 + exit 1 + fi + fi + + # mkdir is the atomic acquisition primitive. POSIX mkdir(2) on an + # existing path returns EEXIST without modifying anything, so it + # serializes concurrent `party host ` cleanly: exactly one + # process owns the directory, the rest see EEXIST and bail. + # + # We deliberately do NOT auto-clean leftover dirs (e.g. from a prior + # SIGKILLed cmd_host that couldn't run its rollback trap). Auto-cleanup + # races with a concurrent in-flight setup: two processes both seeing a + # stale dir would both rm -rf it, and the loser's late rm -rf would + # wipe the winner's freshly-created replacement. + # The recovery path for genuine crash leftovers is a one-shot manual + # `rm -rf $party_dir` followed by retry; the error message says so. + if ! mkdir -m 0700 "$party_dir" 2>/dev/null; then + cat >&2 <&2 + exit 1 + fi + + # Arm rollback ONLY after mkdir succeeded. Earlier (before mkdir), + # arming the trap would let a concurrent host attempt that lost the + # mkdir race tear down the winner's directory and tmux server via + # set -e + EXIT trap. Now the trap fires only on resources this + # process actually created. Disarmed once roster_write commits. + trap ' + "$PARTY_TMUX" -S "$sock" kill-server 2>/dev/null || true + rm -rf "$party_dir" + ' EXIT INT TERM HUP + + chgrp "$TMUX_PARTY_GROUP" "$party_dir" + chmod 0750 "$party_dir" + + # The per-party dir is now group-traversable (0750), but the tmux + # socket from `new-session` below lands at 0600, tmux's server.c + # calls umask(S_IXUSR|S_IRWXG|S_IRWXO) before bind(2), so group/other + # bits are stripped regardless of our umask or process group. Group + # members can stat the socket but cannot connect() until + # apply_party_perms_file widens it AFTER server-access -aw runs. + "$PARTY_TMUX" -S "$sock" new-session -d -s "$name" + + tmux_party_setup_server "$sock" "$name" + + # Auth gate FIRST, server-access runs BEFORE we widen FS perms. + # This closes the connect-before-allowlist race: without this order, + # a group member could connect() the socket between chmod g+rw and + # server-access -a, and tmux would honor an in-flight connection + # that wasn't yet on the allowlist. + + # Capability probe. server-access requires tmux ≥ 3.3; without it + # the auth gate doesn't exist and the FS gate alone would let any + # group member attach. README states the version requirement, but + # silently swallowing errors here would degrade the gate to nothing + # under version skew. Refuse to host + # rather than ship an unenforced auth layer; the rollback trap + # tears down the dedicated tmux server we just started. + if ! "$PARTY_TMUX" -S "$sock" server-access -l >/dev/null 2>&1; then + ver=$("$PARTY_TMUX" -V 2>/dev/null || echo "?") + echo "party: tmux ($ver) does not support 'server-access' (requires tmux ≥ 3.3)." >&2 + exit 1 + fi + + # Add host to allowlist. tmux refuses to modify the server owner's + # own ACL entry ("owns the server, can't change access"), the owner + # has implicit write access, so that specific message is benign + # idempotency. Anything else is a real failure: rollback fires. + err=$("$PARTY_TMUX" -S "$sock" server-access -aw "$USER" 2>&1) || true + case "$err" in + '' | *"owns the server"* | *"can't change access"* ) ;; + *) echo "party: server-access -aw $USER failed: $err" >&2; exit 1 ;; + esac + + # FS gate AFTER the access list is populated. The per-party private + # directory was already chgrp'd + chmod 0750 above, so only the + # socket itself needs widening. + apply_party_perms_file "$sock" + + pid=$("$PARTY_TMUX" -S "$sock" display-message -p '#{pid}') + + roster_write "$rec" \ + HOST_USER "$USER" \ + PARTY_NAME "$name" \ + SOCKET "$sock" \ + SERVER_PID "$pid" \ + GROUP "$TMUX_PARTY_GROUP" \ + CREATED "$(iso_now)" + + # Party committed. Disarm rollback, from here on, a failure (e.g. + # chgrp on the roster) leaves a fully-formed party that the host can + # tear down explicitly with `party close`. Tearing it down on the + # caller's behalf for a stray permission warning would be worse. + trap - EXIT INT TERM HUP + + # Roster: chgrp only. roster_write already left mode 0640 (g+r, no g+w). + # Guests read records to discover/join; only the host writes them. Keeping + # them non-group-writable means a tampered SOCKET=... can't redirect + # cmd_close's destructive cleanup at peer-supplied paths. + chgrp "$TMUX_PARTY_GROUP" "$rec" + + if [ -z "${TMUX:-}" ] && [ -t 1 ]; then + echo "Party '$name' started. Attaching..." + exec "$PARTY_TMUX" -S "$sock" attach -t "$name" + fi + echo "Party '$name' started. Attach from a fresh shell with: party join $name" +} + +cmd_close() { + name= + while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + echo "Usage: party close [name]"; exit 0 ;; + -*) echo "party close: unknown flag '$1'" >&2; exit 2 ;; + *) + if [ -z "$name" ]; then name="$1"; shift + else echo "party close: unexpected arg '$1'" >&2; exit 2; fi + ;; + esac + done + + if [ -z "$name" ]; then + # No name → close the only party we host, if exactly one. + # Filter forged dirs (symlinks or non-caller-owned) before + # trusting HOST_USER so another local user can't seed a fake + # party-$USER:junk.d/roster and turn this into a "multiple + # parties" DoS. + set -- + for rec in $(roster_list); do + d="${rec%/roster}" + [ -L "$d" ] && continue + [ -O "$d" ] || continue + roster_read "$rec" || continue + [ "$RR_HOST_USER" = "$USER" ] || continue + set -- "$@" "$RR_PARTY_NAME" + done + case $# in + 0) echo "party close: you are not hosting any party." >&2; exit 1 ;; + 1) name="$1" ;; + *) echo "party close: you host multiple parties; pass a name:" >&2 + echo " $*" >&2; exit 1 ;; + esac + fi + + # Parity with cmd_host's validate_party_name call: reject path + # separators, whitespace, shell metas, oversized input before path + # derivation. + # Hygiene, not the security gate; [ -O ] below is. + validate_party_name "$name" || exit $? + + expected_dir=$(party_dir_path "$USER" "$name") + expected_sock="$expected_dir/sock" + rec="$expected_dir/roster" + + # Refuse before any tmux work if the per-party dir isn't unambiguously + # ours. With a sticky parent (e.g. /tmp), another local user can + # pre-create party-$USER:$name.d as a symlink before we ever host; + # without this gate, kill-server would fire against a forged socket + # path and rm -rf would chase the symlink. + if [ -L "$expected_dir" ]; then + echo "party close: $expected_dir is a symlink; refusing." >&2 + exit 1 + fi + if [ ! -d "$expected_dir" ]; then + echo "party close: no party named '$name'." >&2; exit 1 + fi + if [ ! -O "$expected_dir" ]; then + echo "party close: $expected_dir is not yours; refusing." >&2 + exit 1 + fi + + # Trust anchor (deliberate): paths are derived + # from $USER + $name and gated on [ -L ]/[ -O ]. mkdir(2) sets owner + # to caller's euid; chown requires root; so a same-group attacker + # can't forge a dir that satisfies [ -O ] for our $USER. We do NOT + # consult the roster file here; file content is advisory in this + # codebase, never trusted for destructive operations. + # PARTY_SOCKET_DIR is caller-controlled env, not a privilege + # boundary, so we don't canonicalize it. + "$PARTY_TMUX" -S "$expected_sock" kill-server 2>/dev/null || true + rm -rf "$expected_dir" + echo "Party '$name' closed." +} +cmd_join() { + passive=0 + name= + while [ $# -gt 0 ]; do + case "$1" in + --passive) passive=1; shift ;; + -h|--help) + cat <<'EOF' +Usage: party join [name] [--passive] + name Party to join. Auto-picks if exactly one is running. + --passive Attach read-only to the host's session (mirrored view) + rather than your own guest session. Switch later with + `party role` (note: role changes are session-level, not a + read-only boundary, see `party role --help`). +EOF + exit 0 ;; + -*) echo "party join: unknown flag '$1'" >&2; exit 2 ;; + *) + if [ -z "$name" ]; then name="$1"; shift + else echo "party join: unexpected arg '$1'" >&2; exit 2; fi + ;; + esac + done + + if [ -n "${TMUX:-}" ]; then + echo "party join: detach from your current tmux first." >&2 + exit 1 + fi + + if [ -n "$name" ]; then + # Capture rc explicitly: under set -e a bare `find_party_by_name` + # returning 1 or 2 would exit before the case ran, swallowing the + # error message. + find_party_by_name "$name" && rc=0 || rc=$? + case $rc in + 0) ;; + 1) echo "party: '$name' not found." >&2; exit 1 ;; + 2) exit 1 ;; # find_party_by_name already printed the ambiguity hint + esac + else + pick_live_party || exit 1 + fi + + # Group gate. Check membership against the party's *recorded* group + # (RR_GROUP), the host may have hosted under --group . The + # FS perimeter on the per-party dir already blocks non-group readers + # at roster_list; this preflight just turns that into a friendlier + # error than "party not found" when membership is the actual cause. + party_group="${RR_GROUP:-$TMUX_PARTY_GROUP}" + if ! id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$party_group"; then + cat >&2 </dev/null \ + | awk '{print $1}' | grep -qx "$USER"; then + echo "party: '$RR_PARTY_NAME', ask $RR_HOST_USER to invite you." >&2 + exit 13 + fi + + if [ "$passive" = 1 ]; then + # Mirror the host's pane focus, read-only at the tmux client + # layer. `-r` is the watcher boundary on the bootstrap path: a + # write-capable invitee who chose --passive cannot send keys + # into the host's panes regardless of their server-access ACL + # entry. Role switches done later via `party role` do NOT carry + # this flag (tmux switch-client cannot set read-only state), + # so role-switching is documented as session-level only, not + # a watcher boundary. + attach_cmd="$PARTY_TMUX -S $RR_SOCKET attach-session -r -t $RR_PARTY_NAME" + else + # Active: own session group with an independent window cursor. + # An active guest session survives a client detach, so create it + # only if it isn't already there, a plain new-session on re-join + # would hit "duplicate session" and abort under set -e. + # + # Reusing by name is an accepted insider risk, not a defended one: + # any write-capable invitee can pre-create a session called + # __party_guest_$USER (and even group it to the party), so on a + # shared server no session name is unforgeable. We trust invitees + # by design, that's the whole model. A read-only invitee (-r) + # can't create sessions, so it's not exposed. See README §Trust + # model. + guest="__party_guest_$USER" + "$PARTY_TMUX" -S "$RR_SOCKET" has-session -t "$guest" 2>/dev/null \ + || "$PARTY_TMUX" -S "$RR_SOCKET" new-session -d -t "$RR_PARTY_NAME" -s "$guest" + attach_cmd="$PARTY_TMUX -S $RR_SOCKET attach-session -t $guest" + fi + + if [ "${PARTY_DRY_RUN:-0}" = 1 ]; then + echo "$attach_cmd" + return 0 + fi + # shellcheck disable=SC2086 + exec $attach_cmd +} +cmd_leave() { + case "${1:-}" in + -h|--help) echo "Usage: party leave"; exit 0 ;; + esac + + # Walk every live party; for each one we have any presence on + # (either via __party_guest_$USER or as a client of the host's + # session in passive mode), kill our guest session and detach + # all our clients. + found=0 + for rec in $(roster_list); do + roster_read "$rec" || continue + is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue + attached=0 + "$PARTY_TMUX" -S "$RR_SOCKET" has-session -t "__party_guest_$USER" 2>/dev/null \ + && attached=1 + "$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' 2>/dev/null \ + | grep -qx "$USER" && attached=1 + [ "$attached" = 1 ] || continue + _disconnect_user "$USER" + found=$((found+1)) + done + + [ "$found" -gt 0 ] || { echo "party: not joined to any party." >&2; exit 1; } +} +# Resolve the party the caller hosts. If exactly one matches, sets RR_*; +# if zero or multiple, error. Argument: optional party name to disambiguate. +resolve_authoritative_party() { + target="$1" + set -- + for rec in $(roster_list); do + # Host-only trust: gate before reading the roster so a forged + # party-$USER:fake.d planted by another local user (symlink, or + # a real dir owned by them) cannot drive HOST_USER=$USER through + # this walker. + d="${rec%/roster}" + [ -L "$d" ] && continue + [ -O "$d" ] || continue + roster_read "$rec" || continue + is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue + [ "$RR_HOST_USER" = "$USER" ] || continue + [ -z "$target" ] || [ "$target" = "$RR_PARTY_NAME" ] || continue + set -- "$@" "$rec" + done + case $# in + 0) if [ -n "$target" ]; then + echo "party: '$target' not found, or you are not its host." >&2 + else + echo "party: you do not host any active party." >&2 + fi + return 1 ;; + 1) roster_read "$1"; return 0 ;; + *) echo "party: multiple parties match, pass a name." >&2 + return 1 ;; + esac +} + +cmd_invite() { + user='' readonly=0 partyname='' + while [ $# -gt 0 ]; do + case "$1" in + -r|--read-only) readonly=1; shift ;; + --party) + [ $# -ge 2 ] || { echo "party invite: --party needs an argument" >&2; exit 2; } + partyname="$2"; shift 2 ;; + -h|--help) + echo "Usage: party invite [-r] [--party name]"; exit 0 ;; + -*) echo "party invite: unknown flag '$1'" >&2; exit 2 ;; + *) + if [ -z "$user" ]; then user="$1"; shift + else echo "party invite: unexpected arg '$1'" >&2; exit 2; fi + ;; + esac + done + + [ -n "$user" ] || { echo "Usage: party invite " >&2; exit 2; } + user_exists "$user" || { echo "party invite: no such user '$user'" >&2; exit 1; } + + resolve_authoritative_party "$partyname" || exit 1 + + # The auth gate (server-access) accepts any user, but the FS gate + # (per-party dir mode 0750, group=$RR_GROUP) won't let a non-member + # traverse in. Without this warning, the host sees "Invited X" and + # the guest sees a write(1) ping, but `party join` reports not-found. + # Warn-and-proceed (rather than hard-fail) so a later usermod -aG + # doesn't require a re-invite. + invite_group="${RR_GROUP:-$TMUX_PARTY_GROUP}" + if ! id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$invite_group"; then + cat >&2 </dev/null <" >&2; exit 2; } + resolve_authoritative_party "" || exit 1 + "$PARTY_TMUX" -S "$RR_SOCKET" server-access "$flag" "$user" + echo "$verb $user." +} + +cmd_voice() { _set_user_access voice -w Voiced "${1:-}"; } +cmd_mute() { _set_user_access mute -r Muted "${1:-}"; } + +# Disconnect every client of $user on the resolved party socket and kill +# their guest session if present. Used by both cmd_kick and cmd_detach. +_disconnect_user() { + user="$1" + "$PARTY_TMUX" -S "$RR_SOCKET" kill-session -t "__party_guest_$user" 2>/dev/null || true + "$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_name} #{client_user}' 2>/dev/null \ + | while read -r cname cuser; do + [ "$cuser" = "$user" ] || continue + "$PARTY_TMUX" -S "$RR_SOCKET" detach-client -t "$cname" 2>/dev/null || true + done +} + +cmd_kick() { + user="${1:-}" + [ -n "$user" ] || { echo "Usage: party kick " >&2; exit 2; } + resolve_authoritative_party "" || exit 1 + "$PARTY_TMUX" -S "$RR_SOCKET" server-access -d "$user" 2>/dev/null || true + _disconnect_user "$user" + echo "Kicked $user." +} + +cmd_detach() { + user="${1:-}" + [ -n "$user" ] || { echo "Usage: party detach " >&2; exit 2; } + resolve_authoritative_party "" || exit 1 + _disconnect_user "$user" + echo "Detached $user (still on allowlist)." +} + +cmd_list() { + found=0 + for rec in $(roster_list); do + roster_read "$rec" || continue + is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue + # Output hygiene, NOT a confidentiality control. If inherited ACLs + # widened the per-party dir past its 0750 / group=RR_GROUP mode bits, + # roster_read above has ALREADY parsed host/name/group into vars, + # and a determined reader would just cat the roster file directly, + # never touching `party list`. So this re-check does not close the + # metadata leak; it only stops `party list` from DISPLAYING parties + # whose group the caller isn't in. Confidentiality still rides on + # the FS perimeter alone. + list_group="${RR_GROUP:-$TMUX_PARTY_GROUP}" + id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$list_group" || continue + # Stale records (dead PID, dead socket, planted daemon) are + # filtered out by is_party_alive. They're not auto-cleaned here: + # cmd_host's mkdir-as-lock refuses to take over leftover dirs + # (auto-cleanup races with concurrent in-flight hosts). Recovery + # for a genuine crash leftover is a one-shot + # manual `rm -rf` per the host-side error message. + attendees=$("$PARTY_TMUX" -S "$RR_SOCKET" list-clients 2>/dev/null | wc -l | tr -d ' ') + # Show the group only when it differs from the env/default, to + # keep the common case uncluttered. + group_tag= + if [ -n "$RR_GROUP" ] && [ "$RR_GROUP" != "$TMUX_PARTY_GROUP" ]; then + group_tag=" [group=$RR_GROUP]" + fi + printf '%-12s %-30s %s attendee(s)%s\n' \ + "$RR_HOST_USER" "$RR_PARTY_NAME" "$attendees" "$group_tag" + found=$((found+1)) + done + + if [ "$found" -eq 0 ]; then + echo "no parties found on this host." + fi +} + +cmd_who() { + short=0 partyname='' sockarg='' + while [ $# -gt 0 ]; do + case "$1" in + --short) short=1; shift ;; + --party) + [ $# -ge 2 ] || { echo "party who: --party needs an argument" >&2; exit 2; } + partyname="$2"; shift 2 ;; + --socket) + [ $# -ge 2 ] || { echo "party who: --socket needs an argument" >&2; exit 2; } + sockarg="$2"; shift 2 ;; + -h|--help) + echo "Usage: party who [--short] [--party name|--socket path]" + exit 0 ;; + -*) echo "party who: unknown flag '$1'" >&2; exit 2 ;; + *) partyname="$1"; shift ;; + esac + done + + if [ -n "$sockarg" ]; then + # Status-line widget path: derive host/name from the per-party + # dir basename ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/sock. + sock="$sockarg" + parent="${sock%/*}"; parent="${parent##*/}" + case "$parent" in + party-*:*.d) ;; + *) echo "party who: '$sockarg' is not a party socket" >&2; exit 2 ;; + esac + stripped="${parent#party-}"; stripped="${stripped%.d}" + # Seed RR_HOST_USER / RR_PARTY_NAME from the basename before + # roster_read so the non-short output path never hits an unbound + # var under set -u when the roster file is missing or unreadable. + # roster_read overwrites these with the same values on success. + RR_HOST_USER="${stripped%%:*}" + RR_PARTY_NAME="${stripped#*:}" + rec=$(roster_record_path "$RR_HOST_USER" "$RR_PARTY_NAME") + roster_read "$rec" 2>/dev/null || true + elif [ -n "$partyname" ]; then + find_party_by_name "$partyname" || exit 1 + else + resolve_authoritative_party "" || exit 1 + fi + + sock="${sock:-$RR_SOCKET}" + [ -S "$sock" ] || { echo "party who: socket gone" >&2; exit 1; } + + invited=$("$PARTY_TMUX" -S "$sock" server-access -l 2>/dev/null \ + | awk '{print $1}' | sort -u) + attached=$("$PARTY_TMUX" -S "$sock" list-clients -F '#{client_user}' 2>/dev/null \ + | sort -u) + + if [ "$short" = 1 ]; then + # shellcheck disable=SC2086 # word-split intentional + set -- $attached + nicks=$(printf '%s,' "$@" | sed 's/,$//') + printf 'party: %d (%s)\n' "$#" "$nicks" + return + fi + + echo "Party '$RR_PARTY_NAME' (host: $RR_HOST_USER)" + echo "Invited:" + for u in $invited; do printf ' %s\n' "$u"; done + echo "Attached:" + if [ -z "$attached" ]; then echo " (none)" + else for u in $attached; do printf ' %s\n' "$u"; done; fi +} + +cmd_status() { + case "${1:-}" in + -h|--help) echo "Usage: party status"; exit 0 ;; + esac + + hosting='' joined='' + for rec in $(roster_list); do + roster_read "$rec" || continue + is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue + if [ "$RR_HOST_USER" = "$USER" ]; then + hosting="$hosting $RR_PARTY_NAME" + fi + # Joined covers two cases. Passive: we have a client attached to + # the host's session, list-clients sees it. Active: we have our + # own __party_guest_$USER session, which survives a client detach + # (party leave will still find and clean it up). OR the two so a + # detached active guest still reports as joined. + if "$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' \ + 2>/dev/null | grep -qx "$USER" \ + || "$PARTY_TMUX" -S "$RR_SOCKET" has-session \ + -t "__party_guest_$USER" 2>/dev/null + then + joined="$joined $RR_PARTY_NAME" + fi + done + [ -n "$hosting$joined" ] || { echo "not in any party."; return 0; } + if [ -n "$hosting" ]; then echo "hosting:$hosting"; fi + if [ -n "$joined" ]; then echo "joined: $joined"; fi +} + +# Locate the party socket where the calling user has at least one +# attached client. Sets RR_* on success. Used by cmd_role. +cmd_role() { + sub="${1:-}" + case "$sub" in + -h|--help) + cat <<'EOF' +Usage: party role [active|passive|switch] + (no arg) Print each of your client sessions and its role. + active Move all your clients to your own guest session. + passive Move all your clients to the host's session (mirrored view). + switch Per-client toggle between active and passive. + +Note: role changes flip the session your client is attached to, but +do not change its read-only state. The watcher boundary is set at +attach time by `party join --passive` (or by `party invite -r`); it +can't be toggled later via `party role`. +EOF + exit 0 ;; + ''|active|passive|switch) ;; + *) echo "party role: unknown role '$sub'" >&2; exit 2 ;; + esac + + # Find the party we should target. When $TMUX is set the caller is + # currently inside a tmux session, its first comma-separated field + # is the active socket path, which uniquely identifies the party. + # Without that, fall back to "any party where the caller has a + # client" (correct when the caller is attached to exactly one). + sock='' + host_sess='' + if [ -n "${TMUX:-}" ]; then + tmux_sock="${TMUX%%,*}" + for rec in $(roster_list); do + roster_read "$rec" || continue + is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue + [ "$RR_SOCKET" = "$tmux_sock" ] || continue + sock="$RR_SOCKET"; host_sess="$RR_PARTY_NAME"; break + done + fi + if [ -z "$sock" ]; then + for rec in $(roster_list); do + roster_read "$rec" || continue + is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue + "$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' 2>/dev/null \ + | grep -qx "$USER" || continue + sock="$RR_SOCKET"; host_sess="$RR_PARTY_NAME"; break + done + fi + [ -n "$sock" ] || { echo "party role: not attached to any party." >&2; exit 1; } + guest="__party_guest_$USER" + + # active and switch may switch a client INTO the guest session; create + # it on demand. passive and the no-arg print path don't need it. + case "$sub" in + active|switch) + "$PARTY_TMUX" -S "$sock" has-session -t "$guest" 2>/dev/null \ + || "$PARTY_TMUX" -S "$sock" new-session -d -t "$host_sess" -s "$guest" + ;; + esac + + "$PARTY_TMUX" -S "$sock" list-clients \ + -F '#{client_name} #{client_user} #{client_session}' 2>/dev/null \ + | while read -r cname cuser csess; do + [ "$cuser" = "$USER" ] || continue + case "$sub" in + '') + case "$csess" in + "$guest") printf '%s: active\n' "$cname" ;; + "$host_sess") printf '%s: passive\n' "$cname" ;; + *) printf '%s: %s\n' "$cname" "$csess" ;; + esac ;; + active) target="$guest" ;; + passive) target="$host_sess" ;; + switch) + if [ "$csess" = "$host_sess" ]; then target="$guest" + else target="$host_sess"; fi ;; + esac + if [ -n "$sub" ]; then + "$PARTY_TMUX" -S "$sock" switch-client -c "$cname" -t "$target" 2>/dev/null || true + fi + done +} + +dispatch() { + [ $# -eq 0 ] && { usage >&2; exit 2; } + sub="$1"; shift + case "$sub" in + -h|--help) usage; exit 0 ;; + --version) echo "party $PARTY_VERSION"; exit 0 ;; + host) cmd_host "$@" ;; + close) cmd_close "$@" ;; + join) cmd_join "$@" ;; + leave) cmd_leave "$@" ;; + invite) cmd_invite "$@" ;; + voice|rw) cmd_voice "$@" ;; + mute|ro) cmd_mute "$@" ;; + kick) cmd_kick "$@" ;; + detach) cmd_detach "$@" ;; + role) cmd_role "$@" ;; + list) cmd_list "$@" ;; + who) cmd_who "$@" ;; + status) cmd_status "$@" ;; + *) + echo "party: unknown subcommand '$sub'" >&2 + usage >&2 + exit 2 ;; + esac +} + +# Entrypoint +# ========== + +if [ "${__PARTY_LIB_ONLY:-0}" != "1" ]; then + dispatch "$@" +fi diff --git a/party.1 b/party.1 new file mode 100644 index 0000000..b7c06b2 --- /dev/null +++ b/party.1 @@ -0,0 +1,435 @@ +.\" party - share a tmux session with the people you already work with +.Dd April 30, 2026 +.Dt PARTY 1 +.Os +.Sh NAME +.Nm party +.Nd share a tmux session with the people you already work with +.Sh SYNOPSIS +.Nm +.Cm host +.Op Ar name +.Op Fl -group Ar group +.Nm +.Cm close +.Op Ar name +.Nm +.Cm join +.Op Ar name +.Op Fl -passive +.Nm +.Cm leave +.Nm +.Cm invite +.Ar user +.Op Fl r +.Nm +.Cm voice +.Ar user +.Nm +.Cm mute +.Ar user +.Nm +.Cm kick +.Ar user +.Nm +.Cm detach +.Ar user +.Nm +.Cm role +.Op Cm active | passive | switch +.Nm +.Cm list +.Nm +.Cm who +.Op Fl -short +.Nm +.Cm status +.Sh DESCRIPTION +.Nm +hosts a live +.Xr tmux 1 +session for other users on the same UNIX host, or joins one +someone else is hosting, with the ergonomics of +.Ql screen -x +and none of the copy/paste friction. +Use it to pair-program, mentor, debug an outage together, or demo a +workflow live: same terminal, no screen-sharing software, no +intermediary servers. +.Pp +Each party runs on its own dedicated +.Xr tmux 1 +server, distinct from the host's personal +.Xr tmux 1 . +This keeps +.Xr tmux 1 +.Cm server-access +scoped: inviting someone to your party does not expose your other +sessions. +Two gates protect each party. +The +.Em filesystem gate +is universal across every supported platform: the per-party directory +is +.Xr chgrp 1 Ns 'd +to +.Ev TMUX_PARTY_GROUP +at mode 0750 so only group members can traverse into it; the socket +itself is +.Ql g+rw . +The +.Em auth gate +is +.Xr tmux 1 +.Cm server-access : +a user who passes the FS gate but is not on the allowlist is rejected +at the protocol layer. +Both gates must pass. +.Pp +.Nm +is designed for small, mutually trusted groups: a hacklab, a tech +team, a circle of friends, not strangers across the internet. +The auth gate is authoritative; the FS gate is defense in depth. +.Sh SUBCOMMANDS +.Bl -tag -width Ds +.It Cm host Op Ar name Op Fl -group Ar group +Spawn a dedicated party +.Xr tmux 1 +server. +With no +.Ar name , +a random one is generated. +Only the host is on the allowlist; invite others explicitly with +.Cm invite . +Group precedence: +.Fl -group +flag > +.Ev TMUX_PARTY_GROUP +> the default +.Ql party . +.It Cm close Op Ar name +Tear down the party server and remove its per-party directory. +With no +.Ar name , +closes the only party the caller hosts. +Host-only. +.It Cm join Op Ar name Op Fl -passive +Join a party. +With no +.Ar name +and exactly one party visible, auto-attaches; otherwise presents a +numbered picker. +By default joins in +.Em active +mode (your own session group with an independent window cursor). +.Fl -passive +attaches read-only to the host's session (mirrored view). +The read-only boundary is set at attach time and is not toggled by +later +.Cm role +changes. +.It Cm leave +Detach from the current party and clean up the per-guest session. +.It Cm invite Ar user Op Fl r +Add +.Ar user +to the allowlist as read/write. +.Fl r +or +.Fl -read-only +invites as a watcher. +Best-effort +.Xr write 1 +ping is sent if available. +Host-only. +.It Cm voice Ar user +Promote +.Ar user +to read/write. +Alias: +.Cm rw . +Host-only. +.It Cm mute Ar user +Demote +.Ar user +to read-only. +Alias: +.Cm ro . +Host-only. +.It Cm kick Ar user +Revoke +.Ar user Ns 's +invite, disconnect their client, and kill their guest session. +Host-only. +.It Cm detach Ar user +Disconnect +.Ar user Ns 's +client and kill their guest session, but keep them on the allowlist. +Host-only. +.It Cm role Op Cm active | passive | switch +Flip your clients between the guest session and the host session. +With no argument, prints the current role for each of your clients. +Session-level only: the read-only boundary set at attach time +.Po see +.Cm join Fl -passive +.Pc +is not toggled by role changes +.Po +.Xr tmux 1 +.Cm switch-client +has no read-only flag +.Pc . +.It Cm list +List live parties on this host from the roster. +.It Cm who Op Fl -short +Show invited and attached users for the current party. +.Fl -short +emits a compact form suitable for a status-line widget. +.It Cm status +Show the caller's own state: hosting, attached, or idle. +.El +.Sh ENVIRONMENT +.Bl -tag -width "PARTY_SOCKET_DIR" +.It Ev TMUX_PARTY_GROUP +Shared system group used for socket access. +Default +.Ql party . +Override to reuse an existing group like +.Ql wheel , +.Ql users , +or +.Ql staff , +or pass +.Fl -group Ar name +to +.Cm host . +.It Ev PARTY_SOCKET_DIR +Where each party's per-party private directory (and its socket and +roster) is created. +Default +.Pa /tmp . +.It Ev PARTY_TMUX +.Xr tmux 1 +binary to use. +Default +.Ql tmux . +Override if +.Xr tmux 1 +\(>= 3.3 lives at a non-standard path. +.El +.Sh FILES +.Bl -tag -width Ds +.It Pa ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/ +Per-party private directory, mode 0750, group +.Ev TMUX_PARTY_GROUP . +.It Pa ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/sock +The +.Xr tmux 1 +socket, mode +.Ql g+rw . +.It Pa ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/roster +Bookkeeping: +.Va HOST_USER , +.Va PARTY_NAME , +.Va SOCKET , +.Va SERVER_PID , +.Va GROUP , +.Va CREATED . +Security-relevant fields are re-derived from the directory itself on +every read, not trusted from the file. +.It Pa ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/.party-notify +Auto-generated helper that fans +.Cm display-message +out to every attached client on join/leave. +.El +.Sh EXIT STATUS +.Bl -tag -width Ds +.It 0 +Success. +.It 1 +Operational failure (no such party, not a member of the group, socket +gone, ambiguous match, etc.). +.It 2 +Usage error (unknown subcommand or flag, missing argument, invalid +party name). +.It 13 +.Cm join : +caller is not on the allowlist for the named party. +.El +.Sh EXAMPLES +One-time setup, as root: +.Bd -literal -offset indent +groupadd party +usermod -aG party alice +usermod -aG party bob +.Ed +.Pp +Have alice and bob log out and back in so the new group takes effect. +.Pp +Host a party named +.Ql debug-the-deploy +and invite alice as a watcher and bob as a co-driver: +.Bd -literal -offset indent +$ party host debug-the-deploy +$ party invite alice -r +$ party invite bob +.Ed +.Pp +On bob's side, join the only running party on this host: +.Bd -literal -offset indent +$ party join +.Ed +.Pp +On alice's side, join read-only: +.Bd -literal -offset indent +$ party join debug-the-deploy --passive +.Ed +.Pp +Reuse an existing group instead of creating a new one: +.Bd -literal -offset indent +$ party host debug-the-deploy --group staff +.Ed +.Sh SECURITY +The OS group is the perimeter, +.Xr tmux 1 Ns 's +.Cm server-access +allowlist is the per-user filter. +Both are universal across the supported platforms (Linux, FreeBSD, +NetBSD, OpenBSD, illumos, Solaris, macOS), so the mechanism is one +liner: +.Xr chgrp 1 ++ +.Xr chmod 1 . +No ACL syscalls are invoked. +.Pp +Trust is anchored in unforgeable primitives: +.Xr mkdir 2 +and +.Xr bind 2 +set the owner to the calling effective UID, and +.Xr chown 1 +requires root. +A group member who plants a forged directory cannot fake its +ownership, cannot redirect the socket path, and cannot forge a socket +elsewhere. +.Pp +.Nm +is designed for small, mutually trusted groups: a hacklab, a tech +team, a circle of friends, not strangers across the internet. +It assumes you already know and trust the people you are adding to +the group; it does not try to be a public access-control system. +.Sh CAVEATS +On filesystems with NFSv4-style inherited ACLs (macOS HFS+/APFS, +FreeBSD/illumos ZFS), an ACE on +.Ev PARTY_SOCKET_DIR +can override the mode bits +.Nm +sets, widening access (e.g.\& an inherited +.Ql everyone@:rx +survives our +.Ql chmod 0750 ) +or narrowing it (e.g.\& a +.Ql deny everyone: +blocks group members our +.Xr chgrp 1 +tried to admit). +The script does not invoke +.Xr getfacl 1 +or +.Xr getextattr 8 +to verify effective access; re-introducing per-platform ACL plumbing +would unwind the simplification this design commits to. +The FS gate is therefore best-effort on those filesystems; the +auth gate +.Po +.Xr tmux 1 +.Cm server-access +.Pc +remains authoritative regardless. +The default +.Ev PARTY_SOCKET_DIR Ns = Ns Pa /tmp +rarely has ACL trouble in practice; if you deploy on a custom dir +with restrictive inherited ACLs, expect to handle traversal at the +ACL layer for your intended group members. +Strangers stay out via +.Cm server-access +either way. +.Pp +.Cm server-access +decides who may +.Em attach , +not who can see that a party +.Em exists . +Hiding a party's existence, meaning the host, its name, group, and +creation time recorded in the +.Pa roster , +falls to the FS gate, not the auth gate: on an ACL-shadowed dir where the +mode bits do not hold, a non-member may read that metadata even though +.Cm server-access +still blocks the join. +The +.Cm list +subcommand re-checks group membership before printing, as defense in +depth, but treat discovery as confidential only insofar as the filesystem +enforces it. +.Pp +An active +.Cm join +lands you in your own +.Ql __party_guest_ +session on the shared server. +Any write-capable invitee can create sessions there, including one named +for someone else; if they pre-create yours, your next active +.Cm join +attaches to it. +This is inside the trust boundary by design, not a defended one; +invitees are people you already trust. +If you do not trust an invitee that far, invite them read-only +.Pq Fl r , +which cannot create sessions, or not at all. +.Pp +.Nm +requires +.Xr tmux 1 +\(>= 3.3 for +.Cm server-access . +Older +.Xr tmux 1 +versions are refused at host time rather than degrading to the FS +gate alone. +.Pp +The +.Cm role +subcommand can flip a client between the guest session and the host +session, but it cannot change the read-only state of an attached +client; +.Xr tmux 1 +.Cm switch-client +has no equivalent of +.Cm attach Fl r . +The watcher boundary is set at +.Cm join Fl -passive +time and is fixed for the life of that client. +.Sh SEE ALSO +.Xr tmux 1 , +.Xr screen 1 , +.Xr write 1 , +.Xr chgrp 1 , +.Xr chmod 1 +.Sh HISTORY +.Nm +was written for the UNIX Social Club +.Pq Lk https://club.unix.rocks/ , +inspired by many years of collective working sessions using +.Ql screen -x +across collectives. +.Sh AUTHORS +.Nm +was written by +.An veg +and +.An kol3rby . +See the +.Pa party +script header for additional credits. +Issues and contributions welcome! diff --git a/tests/00-meta.bats b/tests/00-meta.bats new file mode 100644 index 0000000..c5f37e9 --- /dev/null +++ b/tests/00-meta.bats @@ -0,0 +1,20 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { setup_party_sandbox; } +teardown() { teardown_party_sandbox; } + +@test "test harness can locate the party binary path" { + [ -n "$PARTY_BIN" ] + # The file does not need to exist yet at this stage; we're proving + # the path is computed correctly. + case "$PARTY_BIN" in + */party) ;; + *) false ;; + esac +} + +@test "sandbox setup creates the override dir" { + [ -d "$PARTY_SOCKET_DIR" ] +} diff --git a/tests/10-skeleton.bats b/tests/10-skeleton.bats new file mode 100644 index 0000000..51493ed --- /dev/null +++ b/tests/10-skeleton.bats @@ -0,0 +1,33 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { setup_party_sandbox; } +teardown() { teardown_party_sandbox; } + +@test "party with no args prints usage and exits non-zero" { + run "$PARTY_BIN" + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] + [[ "$output" == *"host"* ]] + [[ "$output" == *"join"* ]] +} + +@test "party --help prints usage and exits zero" { + run "$PARTY_BIN" --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "party rejects unknown subcommands" { + run "$PARTY_BIN" rumpelstiltskin + [ "$status" -ne 0 ] + [[ "$output" == *"unknown subcommand"* ]] +} + +@test "library mode: source returns without dispatching" { + load_party_lib + # If the script ran dispatch, we'd never get here. Reaching this + # assertion is the test. + [ -n "${PARTY_VERSION:-}" ] +} diff --git a/tests/20-helpers.bats b/tests/20-helpers.bats new file mode 100644 index 0000000..999bb78 --- /dev/null +++ b/tests/20-helpers.bats @@ -0,0 +1,96 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { + setup_party_sandbox + load_party_lib +} + +# party name validation +# ===================== + +@test "validate_party_name accepts plain names" { + validate_party_name "debug-the-deploy" + validate_party_name "mob_coding" + validate_party_name "p" + validate_party_name "Party-2026" +} + +@test "validate_party_name rejects empty" { + run validate_party_name "" + [ "$status" -ne 0 ] +} + +@test "validate_party_name rejects path separators" { + run validate_party_name "../etc" + [ "$status" -ne 0 ] + run validate_party_name "a/b" + [ "$status" -ne 0 ] +} + +@test "validate_party_name rejects names over 63 chars" { + long=$(printf 'a%.0s' $(seq 1 64)) + run validate_party_name "$long" + [ "$status" -ne 0 ] +} + +@test "validate_party_name rejects whitespace and shell metas" { + for bad in "a b" "a;b" 'a$b' 'a`b' "a'b" 'a"b'; do + run validate_party_name "$bad" + [ "$status" -ne 0 ] || { echo "should have rejected: $bad"; false; } + done +} + +# time +# ==== + +@test "iso_now produces ISO 8601 UTC with Z suffix" { + out=$(iso_now) + case "$out" in + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z) ;; + *) echo "bad timestamp: $out"; false ;; + esac +} + +# user existence +# ============== + +@test "user_exists returns 0 for the running user" { + user_exists "$USER" +} + +@test "user_exists returns nonzero for a fictional user" { + ! user_exists "nonexistent_$(date +%s)_user" +} + +# liveness +# ======== +# +# is_party_alive must not probe the PID with kill -0. POSIX kill(2) is +# allowed to return EPERM when the caller can't signal the target, and +# illumos/BSD/macOS honor that — so kill -0 against another user's PID +# returns nonzero even when the process exists. Linux and Linux-ABI +# zones (LX-branded) are the outliers that return 0; that's why the +# bug hid in the test matrix until a native illumos run surfaced it. +# Discovery now relies on `tmux -S list-clients` alone, which is +# strictly stronger (filters PID-reuse, dead servers, AF_UNIX impostors). + +@test "is_party_alive does not reach for kill -0 (cross-user discovery)" { + body=$(declare -f is_party_alive) + [[ "$body" != *"kill "* ]] || { echo "$body"; false; } +} + +@test "is_party_alive rejects non-numeric PID without invoking tmux" { + ! PARTY_TMUX=/nonexistent/should-not-be-called \ + is_party_alive "" /tmp/should-not-matter + ! PARTY_TMUX=/nonexistent/should-not-be-called \ + is_party_alive "abc" /tmp/should-not-matter +} + +@test "is_party_alive returns nonzero when tmux server is absent" { + # Numeric PID, but no live tmux server at the socket path: tmux call + # fails the handshake and the function returns nonzero. Uses PID 1 + # to also confirm a foreign-uid PID is not preventing the result. + ! is_party_alive 1 "$PARTY_TMP/no-such-sock" +} diff --git a/tests/30-roster.bats b/tests/30-roster.bats new file mode 100644 index 0000000..0bef0ff --- /dev/null +++ b/tests/30-roster.bats @@ -0,0 +1,122 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { + setup_party_sandbox + load_party_lib +} + +@test "roster_record_path composes deterministically" { + out=$(roster_record_path "veg" "debug-the-deploy") + [ "$out" = "$PARTY_SOCKET_DIR/party-veg:debug-the-deploy.d/roster" ] +} + +@test "party_dir_path composes deterministically" { + out=$(party_dir_path "veg" "debug-the-deploy") + [ "$out" = "$PARTY_SOCKET_DIR/party-veg:debug-the-deploy.d" ] +} + +@test "socket_path lives inside the per-party private dir" { + out=$(socket_path "veg" "debug-the-deploy") + [ "$out" = "$PARTY_SOCKET_DIR/party-veg:debug-the-deploy.d/sock" ] +} + +@test "roster_write then roster_read round-trips all fields" { + ensure_party_dir "$USER" "rt-test" + rec=$(roster_record_path "$USER" "rt-test") + # roster_read derives RR_HOST_USER, RR_PARTY_NAME, and RR_SOCKET from + # the dir basename and filesystem; SERVER_PID, GROUP, and CREATED come + # from the file. The values written here for HOST_USER/PARTY_NAME/ + # SOCKET are advisory and overlap with what roster_read derives. + derived_sock="$PARTY_SOCKET_DIR/party-$USER:rt-test.d/sock" + roster_write "$rec" \ + HOST_USER "$USER" \ + PARTY_NAME "rt-test" \ + SOCKET "$derived_sock" \ + SERVER_PID 12345 \ + CREATED "2026-04-26T18:42:00Z" + [ -f "$rec" ] + roster_read "$rec" + [ "$RR_HOST_USER" = "$USER" ] + [ "$RR_PARTY_NAME" = "rt-test" ] + [ "$RR_SOCKET" = "$derived_sock" ] + [ "$RR_SERVER_PID" = "12345" ] + [ "$RR_CREATED" = "2026-04-26T18:42:00Z" ] +} + +@test "roster_write is atomic: no partial files on failure" { + ensure_party_dir "$USER" "atomic" + rec=$(roster_record_path "$USER" "atomic") + roster_write "$rec" HOST_USER "$USER" PARTY_NAME "atomic" \ + SOCKET "/tmp/sock" SERVER_PID 1 CREATED 0 + # The write goes via a temp file; no .tmp leftover should remain in + # the per-party dir. + ! ls "${rec%/*}"/*.tmp 2>/dev/null +} + +@test "roster_read fails on missing file" { + run roster_read "$PARTY_SOCKET_DIR/party-nobody:nope.d/roster" + [ "$status" -ne 0 ] +} + +# Helper: write a $PARTY_TMUX stub that exits with the given code on +# every invocation. Used to test is_party_alive's tmux probe in isolation. +_stub_tmux() { + cat > "$PARTY_TMP/tmux-stub" </dev/null + wait "$pid" 2>/dev/null || true + # Same PID, now reaped — tmux stub still says yes, so still alive. + is_party_alive "$pid" /dev/null + # And a PID we never owned (init/launchd, root) — likewise alive. + is_party_alive 1 /dev/null +} + +@test "is_party_alive: empty/non-numeric PID → false" { + _stub_tmux 0 + ! is_party_alive "" /dev/null + ! is_party_alive "abc" /dev/null +} + +@test "is_party_alive: tmux probe fails → false" { + # PID-reuse / planted nc -lU socket scenario: the socket isn't a real + # tmux server, so the tmux handshake fails. is_party_alive rejects + # regardless of PID state (regression seen cross-user on illumos + # native). + _stub_tmux 1 + ( sleep 30 ) & + pid=$! + ! is_party_alive "$pid" /dev/null + kill "$pid" 2>/dev/null + wait "$pid" 2>/dev/null || true + ! is_party_alive 1 /dev/null +} + +@test "roster_list returns full record paths under PARTY_SOCKET_DIR" { + for n in alpha beta gamma; do + ensure_party_dir "$USER" "$n" + rec=$(roster_record_path "$USER" "$n") + roster_write "$rec" HOST_USER "$USER" PARTY_NAME "$n" \ + SOCKET "/tmp/s" SERVER_PID 1 CREATED 0 + done + out=$(roster_list | sort | tr '\n' ' ') + expected="$PARTY_SOCKET_DIR/party-$USER:alpha.d/roster $PARTY_SOCKET_DIR/party-$USER:beta.d/roster $PARTY_SOCKET_DIR/party-$USER:gamma.d/roster " + [ "$out" = "$expected" ] +} diff --git a/tests/40-perms.bats b/tests/40-perms.bats new file mode 100644 index 0000000..a5b4b18 --- /dev/null +++ b/tests/40-perms.bats @@ -0,0 +1,29 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { + setup_party_sandbox + load_party_lib +} + +@test "apply_party_perms_file sets mode g+rw on a regular file" { + require_party_group + f="$PARTY_TMP/f" + : > "$f" + apply_party_perms_file "$f" + m=$(stat -c '%a' "$f" 2>/dev/null || stat -f '%Lp' "$f") + case "$m" in + *6[0-9]) ;; # group has rw + *) echo "mode=$m"; false ;; + esac + g=$(stat -c '%G' "$f" 2>/dev/null || stat -f '%Sg' "$f") + [ "$g" = "$TMUX_PARTY_GROUP" ] +} + +@test "validate_socket_dir_parent accepts an owner-only-writable parent" { + # PARTY_SOCKET_DIR is the per-party dir's parent. Created at mode 0700 + # by setup_party_sandbox (owner-only, neither group- nor world-writable), + # which validate_socket_dir_parent should accept without complaint. + validate_socket_dir_parent +} diff --git a/tests/50-host.bats b/tests/50-host.bats new file mode 100644 index 0000000..be5ab26 --- /dev/null +++ b/tests/50-host.bats @@ -0,0 +1,92 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { + setup_party_sandbox + require_party_group # need real group membership for chgrp +} + +teardown() { teardown_party_sandbox; } + +# Per-party roster path: ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/roster. +party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; } + +@test "party host creates a roster record and a live tmux server" { + run "$PARTY_BIN" host smoke + [ "$status" -eq 0 ] + [[ "$output" == *"smoke"* ]] + rec=$(party_rec smoke) + [ -f "$rec" ] + grep -q "^PARTY_NAME=smoke$" "$rec" + grep -q "^HOST_USER=$USER$" "$rec" + ! grep -q "^MODE=" "$rec" + ! grep -q "^OPS=" "$rec" + pid=$(awk -F= '$1=="SERVER_PID"{print $2}' "$rec") + kill -0 "$pid" + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + [ -S "$sock" ] + tmux -S "$sock" has-session -t smoke +} + +@test "party host enforces unique party name" { + "$PARTY_BIN" host dupe + run "$PARTY_BIN" host dupe + [ "$status" -ne 0 ] + [[ "$output" == *"already running"* ]] +} + +@test "party host rejects bad names" { + run "$PARTY_BIN" host "../escape" + [ "$status" -ne 0 ] +} + +@test "party host without name uses a default" { + run "$PARTY_BIN" host + [ "$status" -eq 0 ] + ls -d "$PARTY_SOCKET_DIR"/party-"$USER":*.d >/dev/null +} + +@test "party host puts host on access list with write" { + "$PARTY_BIN" host acl + rec=$(party_rec acl) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + tmux -S "$sock" server-access -l | grep -q "^$USER" +} + +@test "party close kills the server and removes the roster record" { + "$PARTY_BIN" host clo + rec=$(party_rec clo) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + pid=$(awk -F= '$1=="SERVER_PID"{print $2}' "$rec") + + run "$PARTY_BIN" close clo + [ "$status" -eq 0 ] + [ ! -f "$rec" ] + ! kill -0 "$pid" 2>/dev/null + [ ! -S "$sock" ] +} + +@test "party close rejects bad names" { + # Previous incarnation of this test edited HOST_USER in the roster + # file and expected close to refuse. That's no longer the trust + # model — host identity is anchored on dir ownership, not file + # content (commit 94befac), so editing HOST_USER is a no-op. + # The "not the host" path now tests as: another user owns the dir, + # which we can't fake without root. Coverage for the symlink and + # forged-field cases lives in tests/95-stub-roundtrip.bats. What we + # add here is parity with `party host rejects bad names`: cmd_close + # validates the name before any path or filesystem work. + run "$PARTY_BIN" close "../escape" + [ "$status" -ne 0 ] +} + +@test "party host puts only the host on the access list (no auto-population)" { + "$PARTY_BIN" host inv + rec=$(party_rec inv) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + count=$(tmux -S "$sock" server-access -l | wc -l) + [ "$count" -eq 1 ] + "$PARTY_BIN" close inv +} + diff --git a/tests/60-list-join-leave.bats b/tests/60-list-join-leave.bats new file mode 100644 index 0000000..4d59107 --- /dev/null +++ b/tests/60-list-join-leave.bats @@ -0,0 +1,135 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { + setup_party_sandbox + require_party_group +} + +teardown() { teardown_party_sandbox; } + +party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; } + +@test "party list shows live parties only" { + "$PARTY_BIN" host alpha + "$PARTY_BIN" host bravo + + # Inject a fake stale record. The per-party dir must exist with the + # right group + mode so roster_list can find the record inside. + zdir="$PARTY_SOCKET_DIR/party-$USER:zombie.d" + mkdir -p "$zdir" + chgrp "$TMUX_PARTY_GROUP" "$zdir" + chmod 0750 "$zdir" + rec="$zdir/roster" + cat > "$rec" < -> SERVER_PID=1 in place. Avoids `sed -i` + # portability snags between GNU and BSD sed. + awk -F= 'BEGIN{OFS="="} $1=="SERVER_PID"{$2="1"} {print}' "$rec" >"$rec.new" + cat "$rec.new" >"$rec" + rm -f "$rec.new" + + run "$PARTY_BIN" list + [ "$status" -eq 0 ] + [[ "$output" == *"crossuser"* ]] + + PARTY_DRY_RUN=1 run "$PARTY_BIN" join crossuser + [ "$status" -eq 0 ] +} + +@test "party join attaches via a guest session in group mode" { + "$PARTY_BIN" host joinable + + rec=$(party_rec joinable) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + + # Joining is interactive (it execs into tmux). We can't test the attach + # without a real terminal, so we exercise the underlying path: with + # PARTY_DRY_RUN=1 the join function builds the guest session and prints + # the command that WOULD be exec'd, then exits. + PARTY_DRY_RUN=1 "$PARTY_BIN" join joinable + + tmux -S "$sock" has-session -t "__party_guest_$USER" +} + +@test "party join with no name auto-attaches single live party" { + "$PARTY_BIN" host onlyone + PARTY_DRY_RUN=1 "$PARTY_BIN" join + rec=$(party_rec onlyone) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + tmux -S "$sock" has-session -t "__party_guest_$USER" +} + +@test "party join with no name and no parties errors clearly" { + run "$PARTY_BIN" join + [ "$status" -ne 0 ] + [[ "$output" == *"no parties"* ]] +} + +@test "party join --passive attaches read-only to host session, no guest session" { + "$PARTY_BIN" host mirror + rec=$(party_rec mirror) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + # --passive must use `attach -r` so a write-capable invitee who chooses + # passive can't type into the host's panes (the README's "watcher" + # boundary). PARTY_DRY_RUN=1 prints the attach command instead of execing. + run env PARTY_DRY_RUN=1 "$PARTY_BIN" join mirror --passive + [ "$status" -eq 0 ] + [[ "$output" == *"attach-session -r -t mirror"* ]] + ! tmux -S "$sock" has-session -t "__party_guest_$USER" 2>/dev/null +} + +@test "party leave kills the guest session and detaches the client" { + "$PARTY_BIN" host leaveme + rec=$(party_rec leaveme) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + + PARTY_DRY_RUN=1 "$PARTY_BIN" join leaveme + tmux -S "$sock" has-session -t "__party_guest_$USER" + + "$PARTY_BIN" leave + ! tmux -S "$sock" has-session -t "__party_guest_$USER" 2>/dev/null +} + +@test "party leave with no joined party errors helpfully" { + run "$PARTY_BIN" leave + [ "$status" -ne 0 ] + [[ "$output" == *"not joined"* ]] +} diff --git a/tests/70-moderation.bats b/tests/70-moderation.bats new file mode 100644 index 0000000..557a4fb --- /dev/null +++ b/tests/70-moderation.bats @@ -0,0 +1,51 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { + setup_party_sandbox + require_party_group +} +teardown() { teardown_party_sandbox; } + +party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; } + +@test "party invite adds user to access list" { + "$PARTY_BIN" host invtest + other=$(pick_other_user); [ -n "$other" ] || skip "no second user" + "$PARTY_BIN" invite "$other" + rec=$(party_rec invtest) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + tmux -S "$sock" server-access -l | grep -q "^$other" +} + +@test "party invite -r adds user as read-only" { + "$PARTY_BIN" host invro + other=$(pick_other_user); [ -n "$other" ] || skip + "$PARTY_BIN" invite "$other" -r + rec=$(party_rec invro) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + tmux -S "$sock" server-access -l | grep "^$other" | grep -qiE 'read|\(R\)' +} + +@test "party voice / party mute toggle write access" { + "$PARTY_BIN" host vmtest + other=$(pick_other_user); [ -n "$other" ] || skip + "$PARTY_BIN" invite "$other" -r + "$PARTY_BIN" voice "$other" + "$PARTY_BIN" mute "$other" +} + +@test "party kick removes from access list and kills guest session" { + "$PARTY_BIN" host ktest + other=$(pick_other_user); [ -n "$other" ] || skip + "$PARTY_BIN" invite "$other" + rec=$(party_rec ktest) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + # Inject a fake guest session as if the user had joined. + tmux -S "$sock" new-session -d -t ktest -s "__party_guest_$other" + "$PARTY_BIN" kick "$other" + ! tmux -S "$sock" server-access -l | grep -q "^$other" + ! tmux -S "$sock" has-session -t "__party_guest_$other" 2>/dev/null +} + diff --git a/tests/80-info.bats b/tests/80-info.bats new file mode 100644 index 0000000..713ed6c --- /dev/null +++ b/tests/80-info.bats @@ -0,0 +1,73 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { + setup_party_sandbox + require_party_group +} +teardown() { teardown_party_sandbox; } + +party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; } + +@test "party who lists invited and attached users" { + "$PARTY_BIN" host whotest + run "$PARTY_BIN" who --party whotest + [ "$status" -eq 0 ] + [[ "$output" == *"Party 'whotest'"* ]] + [[ "$output" == *"Invited:"* ]] + [[ "$output" == *"$USER"* ]] + [[ "$output" == *"Attached:"* ]] +} + +@test "party who --short emits a single line for status-right" { + "$PARTY_BIN" host shorttest + rec=$(party_rec shorttest) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + out=$("$PARTY_BIN" who --short --socket "$sock") + case "$out" in + party:*) ;; + *) echo "got: $out"; false ;; + esac +} + +@test "party who --socket prints Party line (long form)" { + "$PARTY_BIN" host wstest + rec=$(party_rec wstest) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + run "$PARTY_BIN" who --socket "$sock" + [ "$status" -eq 0 ] + [[ "$output" == *"Party 'wstest'"* ]] + [[ "$output" == *"host: $USER"* ]] +} + +@test "party who --socket survives a missing roster (set -u regression)" { + "$PARTY_BIN" host wsroster + rec=$(party_rec wsroster) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + rm -f "$rec" + run "$PARTY_BIN" who --socket "$sock" + [ "$status" -eq 0 ] + [[ "$output" == *"Party 'wsroster'"* ]] + [[ "$output" == *"host: $USER"* ]] +} + +@test "party who --socket rejects a path that is not a party socket" { + run "$PARTY_BIN" who --socket /tmp/not-a-party-socket + [ "$status" -eq 2 ] + [[ "$output" == *"is not a party socket"* ]] +} + +@test "party status reports hosting" { + "$PARTY_BIN" host stat + run "$PARTY_BIN" status + [ "$status" -eq 0 ] + [[ "$output" == *"hosting"* ]] + [[ "$output" == *"stat"* ]] +} + +@test "party status reports nothing when not in or hosting any party" { + run "$PARTY_BIN" status + [ "$status" -eq 0 ] + [[ "$output" == *"not in any party"* ]] +} diff --git a/tests/90-notifications.bats b/tests/90-notifications.bats new file mode 100644 index 0000000..1f6879b --- /dev/null +++ b/tests/90-notifications.bats @@ -0,0 +1,53 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { + setup_party_sandbox + require_party_group +} +teardown() { teardown_party_sandbox; } + +party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; } + +@test "party host installs client-attached and client-detached hooks" { + "$PARTY_BIN" host hooks + rec=$(party_rec hooks) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + out=$(tmux -S "$sock" show-hooks -g) + [[ "$out" == *"client-attached"* ]] + [[ "$out" == *"client-detached"* ]] +} + +@test "status-right widget is configured" { + "$PARTY_BIN" host srtest + rec=$(party_rec srtest) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + sr=$(tmux -S "$sock" show-options -gv status-right) + [[ "$sr" == *"party who --short"* ]] +} + +@test "client-attached hook invokes a notify helper that broadcasts display-message" { + "$PARTY_BIN" host bcast + rec=$(party_rec bcast) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + # The hook body is a run-shell that invokes the notify helper script. + # Verify the hook references the helper, and the helper contains display-message. + hook=$(tmux -S "$sock" show-hooks -g | grep client-attached) + [[ "$hook" == *"run-shell"* ]] + # Extract the notify script path from the hook line and verify its content. + notify_script="${sock%/*}/.party-notify" + [ -f "$notify_script" ] + grep -q "display-message" "$notify_script" +} + +@test ".party-notify is chgrp'd to TMUX_PARTY_GROUP (no :other outlier)" { + "$PARTY_BIN" host notifyperms + rec=$(party_rec notifyperms) + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + notify_script="${sock%/*}/.party-notify" + [ -f "$notify_script" ] + g=$(stat -c '%G' "$notify_script" 2>/dev/null || stat -f '%Sg' "$notify_script") + [ "$g" = "$TMUX_PARTY_GROUP" ] \ + || { echo "notify_script group=$g, expected $TMUX_PARTY_GROUP"; false; } +} diff --git a/tests/95-stub-roundtrip.bats b/tests/95-stub-roundtrip.bats new file mode 100644 index 0000000..629db74 --- /dev/null +++ b/tests/95-stub-roundtrip.bats @@ -0,0 +1,812 @@ +#!/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" +} diff --git a/tests/99-e2e.bats b/tests/99-e2e.bats new file mode 100644 index 0000000..36e237c --- /dev/null +++ b/tests/99-e2e.bats @@ -0,0 +1,45 @@ +#!/usr/bin/env bats + +load 'helpers' + +setup() { + setup_party_sandbox + require_party_group +} +teardown() { teardown_party_sandbox; } + +@test "full lifecycle: host → invite → voice → mute → kick → close" { + other=$(pick_other_user) + [ -n "$other" ] || skip "no second user on this box" + + "$PARTY_BIN" host e2e + + # discover + run "$PARTY_BIN" list + [[ "$output" == *"e2e"* ]] + + # invite + "$PARTY_BIN" invite "$other" -r + rec="$PARTY_SOCKET_DIR/party-$USER:e2e.d/roster" + sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec") + tmux -S "$sock" server-access -l | grep -q "^$other" + + # voice → mute + "$PARTY_BIN" voice "$other" + "$PARTY_BIN" mute "$other" + + # kick + "$PARTY_BIN" kick "$other" + + # close + "$PARTY_BIN" close e2e + [ ! -f "$rec" ] + [ ! -S "$sock" ] +} + +@test "all subcommands appear in --help" { + run "$PARTY_BIN" --help + for sub in host close join leave invite voice mute kick detach role list who status; do + [[ "$output" == *"$sub"* ]] || { echo "missing in --help: $sub"; false; } + done +} diff --git a/tests/helpers.bash b/tests/helpers.bash new file mode 100644 index 0000000..4f17da5 --- /dev/null +++ b/tests/helpers.bash @@ -0,0 +1,136 @@ +# Bats helpers shared by every test file. +# `load 'helpers'` from a .bats file pulls these in. + +# helpers.bash lives at /tests/helpers.bash, so repo root is one level +# up from its own directory. This avoids depending on .git, which may not +# be present on remote test hosts (e.g. rsynced without history). +PARTY_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/party" + +# Per-test scratch dir. Bats sets BATS_TEST_TMPDIR but we centralize so +# party's overridable env vars all point under one tree. +setup_party_sandbox() { + # Pretend the test runner is NOT inside tmux. iTerm2's tmux -CC mode + # (or any nested tmux) sets $TMUX, which several party subcommands + # treat as "you're already attached somewhere." Tests that need + # $TMUX set re-export it themselves. + unset TMUX TMUX_PANE TMUX_TMPDIR + + # USER may not be exported in all environments (e.g. some CI shells, + # zsh with clean env). party uses $USER under set -eu, so guarantee it. + export USER="${USER:-$(id -un)}" + + export PARTY_TMP="$BATS_TEST_TMPDIR/party" + export PARTY_SOCKET_DIR="$PARTY_TMP/sockets" + mkdir -p "$PARTY_SOCKET_DIR" + chmod 0700 "$PARTY_SOCKET_DIR" + # No shared roster directory in this layout. Each per-party dir under + # PARTY_SOCKET_DIR carries its own roster file. validate_socket_dir_parent + # accepts an owner-only-writable parent like this one. +} + +# Tear down any tmux servers we spawned under PARTY_SOCKET_DIR. +# Sockets live at ${PARTY_SOCKET_DIR}/party-USER:NAME.d/sock under the +# per-party-private-dir layout. The old glob (party-*) matched the +# *directory* and the [ -S ] filter dropped everything, so servers +# leaked across tests. +teardown_party_sandbox() { + if [ -d "${PARTY_SOCKET_DIR:-}" ]; then + for d in "$PARTY_SOCKET_DIR"/party-*.d; do + [ -d "$d" ] || continue + sock="$d/sock" + [ -S "$sock" ] || continue + tmux -S "$sock" kill-server 2>/dev/null || true + done + fi +} + +# Pre-create the per-party private dir so roster_write can land its +# record inside. Mirrors cmd_host's mkdir -m 0700 + chmod 0750. Also +# creates a placeholder sock file so roster_read (which requires +# $d/sock to exist and be owned by the basename's HOST_USER) accepts +# the dir. +ensure_party_dir() { + local d="$PARTY_SOCKET_DIR/party-$1:$2.d" + mkdir -p "$d" + chmod 0750 "$d" + : > "$d/sock" +} + +# Skip the test if the running user is not a member of TMUX_PARTY_GROUP. +require_party_group() { + local g="${TMUX_PARTY_GROUP:-party}" + id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$g" \ + || skip "user not in group '$g'; run: sudo usermod -aG $g $USER && relog" +} + +# Print one username per line for users in group $1 — both supplementary +# members (4th /etc/group field) and primary-group members (passwd gid +# match). Linux / illumos / FreeBSD / OpenBSD use getent(1); macOS has +# no getent and stores users in Directory Services, so fall back to +# dscl(1) there. Returns silently if neither resolver is available or +# the group is unknown. +list_users_in_group() { + local g="$1" gid + if command -v getent >/dev/null 2>&1; then + gid=$(getent group "$g" 2>/dev/null | awk -F: '{print $3}') + [ -n "$gid" ] || return 0 + { + getent group "$g" | awk -F: '{n=split($4,m,","); for(i=1;i<=n;i++) if(m[i]!="") print m[i]}' + getent passwd | awk -F: -v gid="$gid" '$4==gid {print $1}' + } + elif command -v dscl >/dev/null 2>&1; then + gid=$(dscl . -read "/Groups/$g" PrimaryGroupID 2>/dev/null \ + | awk '/^PrimaryGroupID:/ {print $2}') + [ -n "$gid" ] || return 0 + { + # Supplementary members. macOS records gid 20 (staff) members as + # primary-only, so this branch is usually empty for staff but + # populates for groups created with `dseditgroup -o edit -a`. + dscl . -read "/Groups/$g" GroupMembership 2>/dev/null \ + | sed -n 's/^GroupMembership: //p' | tr ' ' '\n' + # Primary members: every user whose PrimaryGroupID matches. + dscl . -list /Users PrimaryGroupID | awk -v gid="$gid" '$2==gid {print $1}' + } | grep -v '^$' || true + fi +} + +# Print the login shell of user $1, or nothing if unknown. Mirrors +# list_users_in_group's portability split: getent(1) where present, +# dscl(1) on macOS. +lookup_user_shell() { + local u="$1" + if command -v getent >/dev/null 2>&1; then + getent passwd "$u" 2>/dev/null | awk -F: '{print $7}' + elif command -v dscl >/dev/null 2>&1; then + dscl . -read "/Users/$u" UserShell 2>/dev/null | sed -n 's/^UserShell: //p' + fi +} + +# Pick another invitable user on this box: a member of TMUX_PARTY_GROUP +# (supplementary or primary) other than $USER. Empty output if none. +# Uses group membership rather than a UID floor so it works across +# Linux (uid>=1000), illumos/Solaris (uid>=100), and BSD conventions. +# Excludes root and no-login system accounts (tmux server-access refuses +# to manage root, and system pseudo-users aren't sensible test peers). +pick_other_user() { + local g="${TMUX_PARTY_GROUP:-party}" + local candidates u shell + candidates=$(list_users_in_group "$g") + for u in $candidates; do + [ "$u" = "$USER" ] && continue + [ "$u" = "root" ] && continue + shell=$(lookup_user_shell "$u") + case "$shell" in + *false|*nologin|"") continue ;; + esac + printf '%s\n' "$u" + return 0 + done +} + +# Source ./party in library mode for pure-function tests. +load_party_lib() { + __PARTY_LIB_ONLY=1 + # shellcheck disable=SC1090 + . "$PARTY_BIN" +} diff --git a/tools/post-install.sh b/tools/post-install.sh new file mode 100755 index 0000000..a7c02e7 --- /dev/null +++ b/tools/post-install.sh @@ -0,0 +1,161 @@ +#!/bin/sh +# tools/post-install.sh — wire system PATH after `make install`, and +# reverse it on `make uninstall`. Idempotent on both sides. +# +# Usage: tools/post-install.sh +# +# Behavior by host OS (uname -s): +# +# SunOS (illumos / Solaris) with prefix not in /usr or /usr/local: +# Add or remove $prefix/bin to/from the PATH= line of /etc/default/login +# (override with LOGIN_DEFAULTS=...). illumos man(1) derives the manpath +# from PATH by replacing trailing /bin with /share/man, so wiring PATH +# is enough for `man party` to work without -M. +# +# Linux / *BSD / macOS with prefix in {/usr, /usr/local}: +# Already on default PATH and MANPATH. No-op. +# +# Anything else (custom PREFIX on Linux/BSD/macOS, or unknown OS): +# Print the export lines the user needs to add to a shell rc. +# Do not mutate any system file. +# +# DESTDIR-aware: the Makefile skips this script entirely when DESTDIR is set +# (package staging context — mutating /etc on the build host is wrong). + +set -eu + +usage() { + echo "usage: $0 " >&2 + exit 2 +} + +[ $# -eq 2 ] || usage +action=$1 +prefix=$2 +case "$action" in install|uninstall) ;; *) usage ;; esac +[ -n "$prefix" ] || usage + +bindir="$prefix/bin" +mandir="$prefix/share/man" +LOGIN_DEFAULTS=${LOGIN_DEFAULTS:-/etc/default/login} +uname_s=$(uname -s) + +# Print install- or uninstall-side instructions for setting/clearing PATH and +# MANPATH in a shell rc. Used when we don't (or won't) edit any system file. +print_manual_instructions() { + verb=$1 bd=$2 md=$3 osname=$4 + if [ "$osname" = "Linux" ] || [ "$osname" = "FreeBSD" ] || [ "$osname" = "NetBSD" ] \ + || [ "$osname" = "OpenBSD" ] || [ "$osname" = "DragonFly" ] || [ "$osname" = "Darwin" ]; then + header="PREFIX is non-standard on $osname; not modifying system files." + else + header="Unrecognized OS '$osname'; not modifying system files." + fi + if [ "$verb" = "install" ]; then + cat <&2 + return 0 + fi + if [ ! -w "$file" ]; then + echo "$file is not writable (run as root); skipping PATH wiring." >&2 + return 0 + fi + + tmpf="$file.party.tmp.$$" + # awk exits 0 if it modified the file, 1 if no change was needed. + if awk -v verb="$verb" -v needle="$needle" ' + BEGIN { changed = 0; path_seen = 0 } + /^PATH=/ && !path_seen { + path_seen = 1 + val = substr($0, 6) + n = split(val, parts, ":") + present = 0 + for (i = 1; i <= n; i++) if (parts[i] == needle) present = 1 + + if (verb == "install") { + if (present) { print; next } + if (val == "") print "PATH=" needle + else print $0 ":" needle + changed = 1 + next + } + if (verb == "uninstall") { + if (!present) { print; next } + out = "" + for (i = 1; i <= n; i++) { + if (parts[i] == needle) continue + out = (out == "" ? parts[i] : out ":" parts[i]) + } + print "PATH=" out + changed = 1 + next + } + } + { print } + END { exit changed ? 0 : 1 } + ' "$file" > "$tmpf"; then + ts=$(date +%Y%m%d%H%M%S) + cp -p "$file" "$file.before-party.$ts" + mv "$tmpf" "$file" + case "$verb" in + install) echo "Added $needle to PATH in $file (backup: $file.before-party.$ts)" ;; + uninstall) echo "Removed $needle from PATH in $file (backup: $file.before-party.$ts)" ;; + esac + else + rm -f "$tmpf" + case "$verb" in + install) echo "$needle already in PATH in $file; nothing to do." ;; + uninstall) echo "$needle not in PATH in $file; nothing to do." ;; + esac + fi + return 0 +} + +case "$uname_s" in + SunOS) + case "$prefix" in + /usr|/usr/local) + echo "$prefix is on the default illumos PATH; no system wiring needed." + ;; + *) + edit_path_line "$action" "$LOGIN_DEFAULTS" "$bindir" + ;; + esac + ;; + Linux|FreeBSD|NetBSD|OpenBSD|DragonFly|Darwin) + case "$prefix" in + /usr|/usr/local) + : # already on PATH and MANPATH on these platforms + ;; + *) + print_manual_instructions "$action" "$bindir" "$mandir" "$uname_s" + ;; + esac + ;; + *) + print_manual_instructions "$action" "$bindir" "$mandir" "$uname_s" + ;; +esac