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.
This commit is contained in:
commit
6be0ac1877
20 changed files with 3869 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Test scratch
|
||||
tests/.tmp/
|
||||
77
Makefile
Normal file
77
Makefile
Normal file
|
|
@ -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
|
||||
124
README.md
Normal file
124
README.md
Normal file
|
|
@ -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 <name>` 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.
|
||||
49
deploy.sh
Executable file
49
deploy.sh
Executable file
|
|
@ -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 <host>[: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 <host>[: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"
|
||||
435
party.1
Normal file
435
party.1
Normal file
|
|
@ -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_<you>
|
||||
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!
|
||||
20
tests/00-meta.bats
Normal file
20
tests/00-meta.bats
Normal file
|
|
@ -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" ]
|
||||
}
|
||||
33
tests/10-skeleton.bats
Normal file
33
tests/10-skeleton.bats
Normal file
|
|
@ -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:-}" ]
|
||||
}
|
||||
96
tests/20-helpers.bats
Normal file
96
tests/20-helpers.bats
Normal file
|
|
@ -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"
|
||||
}
|
||||
122
tests/30-roster.bats
Normal file
122
tests/30-roster.bats
Normal file
|
|
@ -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" <<EOF
|
||||
#!/bin/sh
|
||||
exit $1
|
||||
EOF
|
||||
chmod +x "$PARTY_TMP/tmux-stub"
|
||||
PARTY_TMUX="$PARTY_TMP/tmux-stub"
|
||||
}
|
||||
|
||||
@test "is_party_alive: tmux probe ok → true (regardless of PID liveness)" {
|
||||
# Liveness is decided by the tmux probe alone; the PID arg is kept for
|
||||
# caller-API compatibility and numeric-validation, but is not signal-
|
||||
# probed (POSIX kill(2) returns EPERM cross-user on illumos/BSD/macOS,
|
||||
# which broke cross-user discovery in earlier revisions). With a stub
|
||||
# tmux returning 0, both a live and a never-existed PID must pass.
|
||||
_stub_tmux 0
|
||||
( sleep 30 ) &
|
||||
pid=$!
|
||||
is_party_alive "$pid" /dev/null
|
||||
kill "$pid" 2>/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" ]
|
||||
}
|
||||
29
tests/40-perms.bats
Normal file
29
tests/40-perms.bats
Normal file
|
|
@ -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
|
||||
}
|
||||
92
tests/50-host.bats
Normal file
92
tests/50-host.bats
Normal file
|
|
@ -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 <name> 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
|
||||
}
|
||||
|
||||
135
tests/60-list-join-leave.bats
Normal file
135
tests/60-list-join-leave.bats
Normal file
|
|
@ -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" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=zombie
|
||||
SOCKET=$zdir/sock
|
||||
SERVER_PID=999999
|
||||
CREATED=2026-04-26T18:42:00Z
|
||||
EOF
|
||||
chgrp "$TMUX_PARTY_GROUP" "$rec"
|
||||
chmod 0640 "$rec"
|
||||
|
||||
run "$PARTY_BIN" list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"alpha"* ]]
|
||||
[[ "$output" == *"bravo"* ]]
|
||||
[[ "$output" != *"zombie"* ]]
|
||||
}
|
||||
|
||||
@test "party list with no parties is friendly, not noisy" {
|
||||
run "$PARTY_BIN" list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"no parties"* ]]
|
||||
}
|
||||
|
||||
# Cross-user discovery regression. Earlier revisions of is_party_alive
|
||||
# probed the PID with `kill -0`, which returns EPERM under POSIX semantics
|
||||
# when the caller can't signal the target — illumos, all BSDs, and macOS
|
||||
# honor that, so guests couldn't discover parties hosted by other users
|
||||
# on the same box. To simulate the foreign-uid case without privilege,
|
||||
# rewrite the roster's SERVER_PID to 1 (init/launchd, root-owned on every
|
||||
# supported platform) and confirm `party list`/`join` still find it. With
|
||||
# the bug present this test fails on every non-Linux target; on Linux and
|
||||
# Linux-ABI zones it passes regardless because Linux's kill(2) returns 0
|
||||
# for "exists, even if not signalable."
|
||||
@test "party list discovers parties whose host PID we cannot signal" {
|
||||
"$PARTY_BIN" host crossuser
|
||||
|
||||
rec=$(party_rec crossuser)
|
||||
[ -f "$rec" ]
|
||||
# Rewrite SERVER_PID=<our-pid> -> 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 <name> 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"* ]]
|
||||
}
|
||||
51
tests/70-moderation.bats
Normal file
51
tests/70-moderation.bats
Normal file
|
|
@ -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
|
||||
}
|
||||
|
||||
73
tests/80-info.bats
Normal file
73
tests/80-info.bats
Normal file
|
|
@ -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"* ]]
|
||||
}
|
||||
53
tests/90-notifications.bats
Normal file
53
tests/90-notifications.bats
Normal file
|
|
@ -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; }
|
||||
}
|
||||
812
tests/95-stub-roundtrip.bats
Normal file
812
tests/95-stub-roundtrip.bats
Normal file
|
|
@ -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 <name> records the chosen group in the roster" {
|
||||
# The stub doesn't care which group we pick — exercise the path that
|
||||
# plumbs --group through to the GROUP= field. Use the caller's primary
|
||||
# group so chgrp succeeds.
|
||||
primary=$(id -gn)
|
||||
run "$PARTY_BIN" host grouped --group "$primary"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
rec=$(party_rec grouped)
|
||||
[ -f "$rec" ]
|
||||
grep -q "^GROUP=$primary$" "$rec"
|
||||
|
||||
"$PARTY_BIN" close grouped
|
||||
}
|
||||
|
||||
@test "host with --group works without any pre-existing shared install dir" {
|
||||
# Regression: the old layout required /tmp/roster to be installed at
|
||||
# mode 1770 g=$TMUX_PARTY_GROUP at install time. The new layout has
|
||||
# nothing of the sort — every party is self-contained under PARTY_SOCKET_DIR.
|
||||
# Confirm `party host --group` succeeds with a clean sandbox that
|
||||
# never had a shared roster directory.
|
||||
primary=$(id -gn)
|
||||
run "$PARTY_BIN" host noshareddir --group "$primary"
|
||||
[ "$status" -eq 0 ]
|
||||
rec=$(party_rec noshareddir)
|
||||
[ -f "$rec" ]
|
||||
"$PARTY_BIN" close noshareddir
|
||||
}
|
||||
|
||||
@test "host refuses a non-trustworthy PARTY_SOCKET_DIR" {
|
||||
bad="$PARTY_TMP/bad"
|
||||
mkdir -p "$bad"
|
||||
chmod 0777 "$bad" # not sticky, world-writable
|
||||
PARTY_SOCKET_DIR="$bad" run "$PARTY_BIN" host shouldfail
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"refusing to operate"* ]]
|
||||
}
|
||||
|
||||
@test "host refuses group-writable, non-sticky PARTY_SOCKET_DIR" {
|
||||
# Owned by us, mode 0770, no sticky. The parent's group may differ
|
||||
# from TMUX_PARTY_GROUP, so its members would be outside the trust
|
||||
# perimeter yet able to rename or unlink the per-party dir.
|
||||
bad="$PARTY_TMP/groupw"
|
||||
mkdir -p "$bad"
|
||||
chmod 0770 "$bad"
|
||||
PARTY_SOCKET_DIR="$bad" run "$PARTY_BIN" host shouldfail
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"refusing to operate"* ]]
|
||||
}
|
||||
|
||||
@test "host party_dir ends at mode 0750 even with permissive umask" {
|
||||
# Regression for the umask race: with umask 0002, plain `mkdir` would
|
||||
# produce 0775 until the later chmod 0750 closed the window. mkdir -m
|
||||
# 0700 in cmd_host removes the window entirely — and the postcondition
|
||||
# is still 0750.
|
||||
( umask 0002 && "$PARTY_BIN" host umasked )
|
||||
party_dir="$PARTY_SOCKET_DIR/party-$USER:umasked.d"
|
||||
m=$(stat -c '%a' "$party_dir" 2>/dev/null || stat -f '%Lp' "$party_dir")
|
||||
[ "$m" = "750" ]
|
||||
"$PARTY_BIN" close umasked
|
||||
}
|
||||
|
||||
@test "host refuses to bind a socket path past AF_UNIX sun_path" {
|
||||
# Build a long-but-trustworthy parent under PARTY_TMP, then host with a
|
||||
# near-max-length name. Total length should exceed the ~100-byte cap
|
||||
# cmd_host enforces. The error must come from us, not from tmux.
|
||||
pad=$(printf '%080s' '' | tr ' ' p) # 80-char component
|
||||
long="$PARTY_TMP/$pad"
|
||||
mkdir "$long" && chmod 0700 "$long"
|
||||
longname=$(printf '%050s' '' | tr ' ' n) # 50 chars, within validator's 63
|
||||
PARTY_SOCKET_DIR="$long" run "$PARTY_BIN" host "$longname"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"socket path too long"* ]]
|
||||
# And the tmux stub never got invoked for new-session.
|
||||
! grep -q "new-session" "$TMUX_LOG"
|
||||
}
|
||||
|
||||
@test "roster record is mode 0640, not group-writable" {
|
||||
# If the roster is g+w, peers can rewrite SOCKET= to point cmd_close at
|
||||
# arbitrary host-owned paths. Lock it to g+r.
|
||||
"$PARTY_BIN" host modecheck
|
||||
rec=$(party_rec modecheck)
|
||||
m=$(stat -c '%a' "$rec" 2>/dev/null || stat -f '%Lp' "$rec")
|
||||
[ "$m" = "640" ]
|
||||
"$PARTY_BIN" close modecheck
|
||||
}
|
||||
|
||||
@test "join refuses when caller is not in the party's recorded group" {
|
||||
# The cmd_join group check uses RR_GROUP (per-party), not env
|
||||
# TMUX_PARTY_GROUP. Forge a roster record whose GROUP= names a group
|
||||
# nobody is in, point SERVER_PID at the live sleeper so it looks
|
||||
# alive, and verify cmd_join rejects with the friendly diagnostic
|
||||
# before any tmux work.
|
||||
bogus="party_no_such_group_$$"
|
||||
party_dir="$PARTY_SOCKET_DIR/party-$USER:groupgated.d"
|
||||
mkdir -p "$party_dir"
|
||||
chmod 0750 "$party_dir"
|
||||
: > "$party_dir/sock"
|
||||
rec="$party_dir/roster"
|
||||
cat > "$rec" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=groupgated
|
||||
SOCKET=$party_dir/sock
|
||||
SERVER_PID=$FAKE_SERVER_PID
|
||||
GROUP=$bogus
|
||||
CREATED=2026-04-28T00:00:00Z
|
||||
EOF
|
||||
|
||||
run "$PARTY_BIN" join groupgated
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"uses group '$bogus'"* ]]
|
||||
[[ "$output" == *"not a member"* ]]
|
||||
}
|
||||
|
||||
@test "close ignores forged SOCKET= field; uses derived path" {
|
||||
# roster_read derives RR_SOCKET from $d/sock and ignores the file's
|
||||
# SOCKET= field, and cmd_close derives both paths from $USER + $name
|
||||
# without consulting the roster at all. A forged SOCKET= cannot
|
||||
# redirect the destructive ops at unrelated paths.
|
||||
"$PARTY_BIN" host tampered
|
||||
|
||||
rec=$(party_rec tampered)
|
||||
party_dir="$PARTY_SOCKET_DIR/party-$USER:tampered.d"
|
||||
[ -d "$party_dir" ]
|
||||
|
||||
sentinel="$PARTY_TMP/sentinel"
|
||||
mkdir -p "$sentinel"
|
||||
: > "$sentinel/canary"
|
||||
|
||||
# Forge SOCKET= to point inside the sentinel.
|
||||
awk -v new="$sentinel/sock" 'BEGIN{FS=OFS="="} $1=="SOCKET"{$2=new}1' \
|
||||
"$rec" > "$rec.new" && mv "$rec.new" "$rec"
|
||||
|
||||
run "$PARTY_BIN" close tampered
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Sentinel survives: cmd_close used the derived path, not the forged
|
||||
# SOCKET=. This is the security property.
|
||||
[ -d "$sentinel" ]
|
||||
[ -f "$sentinel/canary" ]
|
||||
# The real per-party dir is cleaned up regardless.
|
||||
[ ! -d "$party_dir" ]
|
||||
}
|
||||
|
||||
@test "close refuses to operate on a symlinked per-party dir" {
|
||||
# With a sticky parent (e.g. /tmp), another local user can pre-create
|
||||
# party-$USER:<name>.d as a symlink before we ever host. cmd_close
|
||||
# must lstat-and-refuse before reading the (potentially forged) roster
|
||||
# or invoking tmux against a symlinked socket path.
|
||||
victim="$PARTY_TMP/victim"
|
||||
mkdir -p "$victim"
|
||||
: > "$victim/sock"
|
||||
cat > "$victim/roster" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=hijack
|
||||
SOCKET=$victim/sock
|
||||
SERVER_PID=$FAKE_SERVER_PID
|
||||
GROUP=$TMUX_PARTY_GROUP
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
|
||||
party_dir="$PARTY_SOCKET_DIR/party-$USER:hijack.d"
|
||||
ln -s "$victim" "$party_dir"
|
||||
|
||||
run "$PARTY_BIN" close hijack
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"symlink"* ]]
|
||||
# tmux must not have been touched: the recorder log has nothing for
|
||||
# this party.
|
||||
! grep -q "kill-server" "$TMUX_LOG"
|
||||
# Symlink and victim contents both intact — we did not chase the
|
||||
# symlink to delete the attacker's tree.
|
||||
[ -L "$party_dir" ]
|
||||
[ -d "$victim" ]
|
||||
[ -f "$victim/roster" ]
|
||||
}
|
||||
|
||||
@test "host warns when PARTY_SOCKET_DIR is not group/world readable" {
|
||||
# The sandbox parent is mode 0700 — trustworthy but undiscoverable to
|
||||
# invitees. cmd_host should accept it (validator is happy) but emit
|
||||
# a warning so the host notices before sending invites.
|
||||
run "$PARTY_BIN" host noreach
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"not readable"* ]]
|
||||
[[ "$output" == *"discover"* ]]
|
||||
"$PARTY_BIN" close noreach
|
||||
}
|
||||
|
||||
@test "resolve_authoritative_party skips symlinked party dirs but roster_list does not" {
|
||||
load_party_lib
|
||||
ensure_party_dir "$USER" real
|
||||
rec_real="$PARTY_SOCKET_DIR/party-$USER:real.d/roster"
|
||||
cat >"$rec_real" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=real
|
||||
SOCKET=$PARTY_SOCKET_DIR/party-$USER:real.d/sock
|
||||
SERVER_PID=$$
|
||||
GROUP=${TMUX_PARTY_GROUP:-party}
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$rec_real"
|
||||
|
||||
# Forge a symlink-shaped fake dir pointing at attacker-controlled content.
|
||||
victim="$BATS_TEST_TMPDIR/victim"
|
||||
mkdir -p "$victim"
|
||||
cat >"$victim/roster" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=fake
|
||||
SOCKET=/tmp/attacker.sock
|
||||
SERVER_PID=$$
|
||||
GROUP=${TMUX_PARTY_GROUP:-party}
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
ln -s "$victim" "$PARTY_SOCKET_DIR/party-$USER:fake.d"
|
||||
|
||||
# roster_list is the cross-user discovery primitive; it must surface
|
||||
# both entries so guests can find host-foreign parties.
|
||||
out=$(roster_list)
|
||||
[[ "$out" == *"party-$USER:real.d/roster"* ]]
|
||||
[[ "$out" == *"party-$USER:fake.d/roster"* ]]
|
||||
|
||||
# resolve_authoritative_party is the host-only walker; without its
|
||||
# symlink gate, two candidates would match HOST_USER=$USER and the
|
||||
# forged one could redirect host-only commands. With the gate, only
|
||||
# the real entry resolves.
|
||||
resolve_authoritative_party ""
|
||||
[ "$RR_PARTY_NAME" = "real" ]
|
||||
}
|
||||
|
||||
@test "no-arg cmd_close ignores forged symlinked party dirs" {
|
||||
# Set up one real party dir with a roster that looks host-owned.
|
||||
ensure_party_dir "$USER" real
|
||||
rec_real="$PARTY_SOCKET_DIR/party-$USER:real.d/roster"
|
||||
cat >"$rec_real" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=real
|
||||
SOCKET=$PARTY_SOCKET_DIR/party-$USER:real.d/sock
|
||||
SERVER_PID=$FAKE_SERVER_PID
|
||||
GROUP=$TMUX_PARTY_GROUP
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$rec_real"
|
||||
# Create a placeholder socket so apply_party_perms_file in close doesn't
|
||||
# choke; close runs kill-server then rm -rf, so just a file is enough.
|
||||
: > "$PARTY_SOCKET_DIR/party-$USER:real.d/sock"
|
||||
|
||||
# Forge a symlinked fake dir: attacker plants party-$USER:fake.d -> victim.
|
||||
victim="$BATS_TEST_TMPDIR/victim"
|
||||
mkdir -p "$victim"
|
||||
cat >"$victim/roster" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=fake
|
||||
SOCKET=$victim/sock
|
||||
SERVER_PID=$FAKE_SERVER_PID
|
||||
GROUP=$TMUX_PARTY_GROUP
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
ln -s "$victim" "$PARTY_SOCKET_DIR/party-$USER:fake.d"
|
||||
|
||||
# no-arg close: should find exactly one candidate (real), close it, exit 0.
|
||||
# If the forged dir were not filtered, it would see two candidates and
|
||||
# exit 1 with "you host multiple parties".
|
||||
run "$PARTY_BIN" close
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"real"* ]]
|
||||
|
||||
# The real party dir must be gone.
|
||||
[ ! -d "$PARTY_SOCKET_DIR/party-$USER:real.d" ]
|
||||
|
||||
# kill-server must not have been invoked against any path under victim.
|
||||
! grep -q "$victim" "$TMUX_LOG"
|
||||
}
|
||||
|
||||
@test "roster_read derives RR_SOCKET from dir; ignores file SOCKET= field" {
|
||||
load_party_lib
|
||||
|
||||
ensure_party_dir "$USER" derived
|
||||
rec="$PARTY_SOCKET_DIR/party-$USER:derived.d/roster"
|
||||
cat >"$rec" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=derived
|
||||
SOCKET=/tmp/decoy-sock-$$
|
||||
SERVER_PID=$$
|
||||
GROUP=${TMUX_PARTY_GROUP:-party}
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$rec"
|
||||
|
||||
roster_read "$rec"
|
||||
# RR_SOCKET is the co-located sock, not the forged path in the file.
|
||||
[ "$RR_SOCKET" = "$PARTY_SOCKET_DIR/party-$USER:derived.d/sock" ]
|
||||
}
|
||||
|
||||
@test "roster_read derives RR_HOST_USER and RR_PARTY_NAME from basename; ignores file fields" {
|
||||
load_party_lib
|
||||
|
||||
ensure_party_dir "$USER" bona-fide
|
||||
rec="$PARTY_SOCKET_DIR/party-$USER:bona-fide.d/roster"
|
||||
# File claims wrong HOST_USER and PARTY_NAME; basename is authoritative.
|
||||
cat >"$rec" <<EOF
|
||||
HOST_USER=lies
|
||||
PARTY_NAME=alsolies
|
||||
SOCKET=/wrong/sock
|
||||
SERVER_PID=$$
|
||||
GROUP=${TMUX_PARTY_GROUP:-party}
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$rec"
|
||||
|
||||
roster_read "$rec"
|
||||
[ "$RR_HOST_USER" = "$USER" ]
|
||||
[ "$RR_PARTY_NAME" = "bona-fide" ]
|
||||
}
|
||||
|
||||
@test "roster_read rejects roster whose sock is a symlink" {
|
||||
load_party_lib
|
||||
|
||||
ensure_party_dir "$USER" symtrap
|
||||
d="$PARTY_SOCKET_DIR/party-$USER:symtrap.d"
|
||||
rm -f "$d/sock"
|
||||
|
||||
victim="$BATS_TEST_TMPDIR/victim_sock"
|
||||
: > "$victim"
|
||||
ln -s "$victim" "$d/sock"
|
||||
|
||||
rec="$d/roster"
|
||||
cat >"$rec" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=symtrap
|
||||
SOCKET=$d/sock
|
||||
SERVER_PID=$$
|
||||
GROUP=${TMUX_PARTY_GROUP:-party}
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$rec"
|
||||
|
||||
! roster_read "$rec"
|
||||
}
|
||||
|
||||
@test "roster_read rejects dir whose owner does not match basename HOST_USER" {
|
||||
[ "$USER" = "root" ] && skip "running as root: -user root would trivially match"
|
||||
load_party_lib
|
||||
|
||||
# Basename claims the dir is hosted by 'root', but mkdir runs as us.
|
||||
fake="$PARTY_SOCKET_DIR/party-root:foo.d"
|
||||
mkdir -p "$fake"
|
||||
chmod 0750 "$fake"
|
||||
: > "$fake/sock"
|
||||
|
||||
rec="$fake/roster"
|
||||
cat >"$rec" <<EOF
|
||||
HOST_USER=root
|
||||
PARTY_NAME=foo
|
||||
SOCKET=$fake/sock
|
||||
SERVER_PID=$$
|
||||
GROUP=${TMUX_PARTY_GROUP:-party}
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$rec"
|
||||
|
||||
# Owner of $fake is $USER, but basename says root → reject.
|
||||
! roster_read "$rec"
|
||||
}
|
||||
|
||||
@test "cmd_leave ignores forged roster pointing at a different socket" {
|
||||
# Set up a legitimate party that the "victim" (us) is joined to.
|
||||
# The stub answers has-session with success so _disconnect_user fires.
|
||||
legit_dir="$PARTY_SOCKET_DIR/party-$USER:legit.d"
|
||||
mkdir -p "$legit_dir" && chmod 0750 "$legit_dir"
|
||||
: > "$legit_dir/sock"
|
||||
cat >"$legit_dir/roster" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=legit
|
||||
SOCKET=$legit_dir/sock
|
||||
SERVER_PID=$FAKE_SERVER_PID
|
||||
GROUP=$TMUX_PARTY_GROUP
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$legit_dir/roster"
|
||||
|
||||
# Mallory plants a forged party dir whose roster points at a victim socket.
|
||||
victim_sock="$BATS_TEST_TMPDIR/victim-sock"
|
||||
trap_dir="$PARTY_SOCKET_DIR/party-mallory:trap.d"
|
||||
mkdir -p "$trap_dir" && chmod 0750 "$trap_dir"
|
||||
cat >"$trap_dir/roster" <<EOF
|
||||
HOST_USER=mallory
|
||||
PARTY_NAME=trap
|
||||
SOCKET=$victim_sock
|
||||
SERVER_PID=$FAKE_SERVER_PID
|
||||
GROUP=$TMUX_PARTY_GROUP
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$trap_dir/roster"
|
||||
|
||||
# Wire up the stub: has-session returns 0 for the legit party so
|
||||
# cmd_leave considers us attached there; list-clients returns our USER.
|
||||
cat > "$PARTY_TMP/tmux-stub" <<STUB
|
||||
#!/bin/sh
|
||||
printf '%s\n' "\$*" >> "\$TMUX_LOG"
|
||||
sock=
|
||||
prev=
|
||||
for a in "\$@"; do
|
||||
if [ "\$prev" = "-S" ]; then sock="\$a"; fi
|
||||
prev="\$a"
|
||||
done
|
||||
case "\$*" in
|
||||
*"new-session -d "*)
|
||||
[ -n "\$sock" ] && : > "\$sock"
|
||||
;;
|
||||
*"display-message -p "*)
|
||||
echo "\$FAKE_SERVER_PID"
|
||||
;;
|
||||
*"has-session"*)
|
||||
exit 0
|
||||
;;
|
||||
*"list-clients"*)
|
||||
printf '#{client_user}\n' | sed "s/.*/$USER/"
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$PARTY_TMP/tmux-stub"
|
||||
|
||||
run "$PARTY_BIN" leave
|
||||
|
||||
# cmd_leave must not have contacted the victim socket.
|
||||
! grep -qF "$victim_sock" "$TMUX_LOG"
|
||||
|
||||
# It must have operated on the legit party socket instead.
|
||||
grep -q "$legit_dir/sock" "$TMUX_LOG"
|
||||
}
|
||||
|
||||
@test "host rolls back on partial setup failure (no orphan dir or server)" {
|
||||
# Without rollback, a failure between new-session and roster_write
|
||||
# leaves a live tmux server with no record. The trap inside cmd_host
|
||||
# must kill-server and rm -rf the per-party dir before exiting.
|
||||
party_dir="$PARTY_SOCKET_DIR/party-$USER:rollback.d"
|
||||
|
||||
FAIL_ON_SETHOOK=1 run "$PARTY_BIN" host rollback
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# Trap fired: kill-server invoked against the derived socket.
|
||||
grep -q "$party_dir/sock kill-server" "$TMUX_LOG"
|
||||
|
||||
# Per-party dir is gone — no leftover roster, no leftover sock.
|
||||
[ ! -e "$party_dir" ]
|
||||
}
|
||||
|
||||
@test "host refuses leftover per-party dir without a roster" {
|
||||
# Simulate a prior incarnation that left a per-party dir behind with
|
||||
# no roster (the rollback trap couldn't fire — SIGKILL or panic).
|
||||
# cmd_host's mkdir-as-lock acquisition will fail with EEXIST. We
|
||||
# deliberately do NOT auto-clean: the same code path triggers on a
|
||||
# concurrent in-flight host attempt for the same name, and racing
|
||||
# rm -rf calls would wipe each other's fresh dirs.
|
||||
# Recovery is a manual `rm -rf` per the error message.
|
||||
party_dir="$PARTY_SOCKET_DIR/party-$USER:resurrect.d"
|
||||
mkdir -p "$party_dir"
|
||||
: > "$party_dir/sock"
|
||||
|
||||
run "$PARTY_BIN" host resurrect
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"already exists"* ]]
|
||||
[[ "$output" == *"rm -rf $party_dir"* ]]
|
||||
|
||||
# The leftover dir is untouched — proves we did not race-clean.
|
||||
[ -d "$party_dir" ]
|
||||
[ -e "$party_dir/sock" ]
|
||||
|
||||
# And we never spoke to tmux at all on the refusal path.
|
||||
! grep -q "kill-server" "$TMUX_LOG"
|
||||
! grep -q "new-session" "$TMUX_LOG"
|
||||
}
|
||||
|
||||
@test "roster_read rejects per-party dir with other-readable mode bits" {
|
||||
# cmd_host writes the per-party dir as 0750 (no other-bits). A non-member
|
||||
# outside TMUX_PARTY_GROUP cannot traverse such a dir. Anything appearing
|
||||
# in roster_list with o+r/o+w/o+x is a spoof attempt: a local user planted
|
||||
# a world-readable party-attacker:foo.d under a sticky parent (e.g. /tmp)
|
||||
# to inject a fake party into someone else's `party list`. roster_read
|
||||
# must reject before tmux is contacted.
|
||||
load_party_lib
|
||||
ensure_party_dir "$USER" worldread
|
||||
d="$PARTY_SOCKET_DIR/party-$USER:worldread.d"
|
||||
chmod 0755 "$d"
|
||||
rec="$d/roster"
|
||||
cat >"$rec" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=worldread
|
||||
SOCKET=$d/sock
|
||||
SERVER_PID=$$
|
||||
GROUP=${TMUX_PARTY_GROUP:-party}
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$rec"
|
||||
! roster_read "$rec"
|
||||
}
|
||||
|
||||
@test "roster_read rejects basename whose party name fails validate_party_name" {
|
||||
# The basename glob (party-*:*.d) is permissive — names like '.' (basename
|
||||
# party-USER:..d) match it. cmd_host's validate_party_name rejects '.' as
|
||||
# a reserved name; roster_read runs the same check on read so a forged
|
||||
# dir whose basename derives an illegal name can't pass.
|
||||
load_party_lib
|
||||
d="$PARTY_SOCKET_DIR/party-$USER:..d"
|
||||
mkdir -p "$d"
|
||||
chmod 0750 "$d"
|
||||
: > "$d/sock"
|
||||
rec="$d/roster"
|
||||
cat >"$rec" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=.
|
||||
SOCKET=$d/sock
|
||||
SERVER_PID=$$
|
||||
GROUP=${TMUX_PARTY_GROUP:-party}
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$rec"
|
||||
! roster_read "$rec"
|
||||
}
|
||||
|
||||
@test "cmd_list skips parties whose tmux list-clients fails" {
|
||||
# SERVER_PID liveness alone doesn't prove a party is real — init's PID 1
|
||||
# always passes, and a non-tmux daemon (or `nc -lU`) can hold a real
|
||||
# AF_UNIX socket. cmd_list must run list-clients and treat non-zero as
|
||||
# "not a real party," skipping the entry rather than reporting it with
|
||||
# 0 attendees.
|
||||
legit_dir="$PARTY_SOCKET_DIR/party-$USER:legit.d"
|
||||
mkdir -p "$legit_dir" && chmod 0750 "$legit_dir"
|
||||
: > "$legit_dir/sock"
|
||||
cat >"$legit_dir/roster" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=legit
|
||||
SOCKET=$legit_dir/sock
|
||||
SERVER_PID=$FAKE_SERVER_PID
|
||||
GROUP=$TMUX_PARTY_GROUP
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$legit_dir/roster"
|
||||
|
||||
fake_dir="$PARTY_SOCKET_DIR/party-$USER:imposter.d"
|
||||
mkdir -p "$fake_dir" && chmod 0750 "$fake_dir"
|
||||
: > "$fake_dir/sock"
|
||||
cat >"$fake_dir/roster" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=imposter
|
||||
SOCKET=$fake_dir/sock
|
||||
SERVER_PID=$FAKE_SERVER_PID
|
||||
GROUP=$TMUX_PARTY_GROUP
|
||||
CREATED=2026-04-29T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$fake_dir/roster"
|
||||
|
||||
# Stub: list-clients exits 1 against the imposter sock, 0 (empty list)
|
||||
# against the legit one. Anything else is a no-op so the rest of cmd_list
|
||||
# behaves normally.
|
||||
cat > "$PARTY_TMUX" <<STUB
|
||||
#!/bin/sh
|
||||
printf '%s\n' "\$*" >> "\$TMUX_LOG"
|
||||
case "\$*" in
|
||||
*list-clients*)
|
||||
case "\$*" in
|
||||
*party-$USER:imposter.d*) exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$PARTY_TMUX"
|
||||
|
||||
run "$PARTY_BIN" list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"legit"* ]]
|
||||
[[ "$output" != *"imposter"* ]]
|
||||
}
|
||||
|
||||
@test "invite warns when invitee is not in the party's group" {
|
||||
# Need a real OS user that's not in TMUX_PARTY_GROUP. 'nobody' exists
|
||||
# on Linux, FreeBSD, OpenBSD, macOS, and illumos; on the matrix it
|
||||
# is never a member of the host's primary group.
|
||||
id -u nobody >/dev/null 2>&1 || skip "no 'nobody' user on this host"
|
||||
if id -nG nobody 2>/dev/null | tr ' ' '\n' | grep -qx "$TMUX_PARTY_GROUP"; then
|
||||
skip "nobody is unexpectedly a member of $TMUX_PARTY_GROUP on this host"
|
||||
fi
|
||||
|
||||
"$PARTY_BIN" host inv >/dev/null
|
||||
|
||||
run "$PARTY_BIN" invite nobody --party inv
|
||||
[ "$status" -eq 0 ]
|
||||
# Warning surfaces with the right group name and remediation.
|
||||
[[ "$output" == *"not in group '$TMUX_PARTY_GROUP'"* ]]
|
||||
[[ "$output" == *"usermod -aG $TMUX_PARTY_GROUP nobody"* ]]
|
||||
# Warn-and-proceed: the auth-list mutation still happened, so a later
|
||||
# group grant doesn't require a re-invite.
|
||||
[[ "$output" == *"Invited nobody"* ]]
|
||||
grep -q "server-access -aw nobody" "$TMUX_LOG"
|
||||
}
|
||||
|
||||
@test "find_party_by_name skips parties whose tmux list-clients fails" {
|
||||
# Parity with test 76 ('cmd_list skips parties...'), but for the join
|
||||
# resolver. A roster can have a live SERVER_PID (PID reuse, init's pid 1)
|
||||
# and an [ -S ]-passing socket (planted via `nc -lU`) yet not be a real
|
||||
# tmux server. is_party_alive must probe list-clients on every resolver,
|
||||
# not just cmd_list — otherwise `party join
|
||||
# imposter` would resolve to the imposter and try to attach.
|
||||
legit_dir="$PARTY_SOCKET_DIR/party-$USER:legit2.d"
|
||||
mkdir -p "$legit_dir" && chmod 0750 "$legit_dir"
|
||||
: > "$legit_dir/sock"
|
||||
cat >"$legit_dir/roster" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=legit2
|
||||
SOCKET=$legit_dir/sock
|
||||
SERVER_PID=$FAKE_SERVER_PID
|
||||
GROUP=$TMUX_PARTY_GROUP
|
||||
CREATED=2026-04-30T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$legit_dir/roster"
|
||||
|
||||
fake_dir="$PARTY_SOCKET_DIR/party-$USER:imposter2.d"
|
||||
mkdir -p "$fake_dir" && chmod 0750 "$fake_dir"
|
||||
: > "$fake_dir/sock"
|
||||
cat >"$fake_dir/roster" <<EOF
|
||||
HOST_USER=$USER
|
||||
PARTY_NAME=imposter2
|
||||
SOCKET=$fake_dir/sock
|
||||
SERVER_PID=$FAKE_SERVER_PID
|
||||
GROUP=$TMUX_PARTY_GROUP
|
||||
CREATED=2026-04-30T00:00:00Z
|
||||
EOF
|
||||
chmod 0640 "$fake_dir/roster"
|
||||
|
||||
cat > "$PARTY_TMUX" <<STUB
|
||||
#!/bin/sh
|
||||
printf '%s\n' "\$*" >> "\$TMUX_LOG"
|
||||
case "\$*" in
|
||||
*list-clients*)
|
||||
case "\$*" in
|
||||
*party-$USER:imposter2.d*) exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$PARTY_TMUX"
|
||||
|
||||
# cmd_join refuses to attach when already inside tmux; force-clear so
|
||||
# the resolver runs to completion without the early bail.
|
||||
unset TMUX
|
||||
|
||||
run "$PARTY_BIN" join imposter2
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"not found"* ]]
|
||||
}
|
||||
|
||||
@test "host refuses to start when tmux lacks server-access (auth gate missing)" {
|
||||
# cmd_host MUST require server-access at runtime, not just in the
|
||||
# README. tmux < 3.3 doesn't support the command, and silently
|
||||
# swallowing its failure (the prior behavior) would degrade the auth
|
||||
# gate to nothing — group members with FS access could attach without
|
||||
# being invited.
|
||||
cat > "$PARTY_TMUX" <<'STUB'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$TMUX_LOG"
|
||||
sock=
|
||||
prev=
|
||||
for a in "$@"; do
|
||||
if [ "$prev" = "-S" ]; then sock="$a"; fi
|
||||
prev="$a"
|
||||
done
|
||||
case "$*" in
|
||||
*"new-session -d "*)
|
||||
[ -n "$sock" ] && : > "$sock"
|
||||
;;
|
||||
*"display-message -p "*)
|
||||
echo "$FAKE_SERVER_PID"
|
||||
;;
|
||||
*"server-access "*)
|
||||
# Simulate tmux < 3.3 — command unknown.
|
||||
echo "unknown command: server-access" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$PARTY_TMUX"
|
||||
|
||||
run "$PARTY_BIN" host noauth
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"server-access"* ]]
|
||||
[[ "$output" == *"3.3"* ]]
|
||||
|
||||
# Rollback: trap fired, party dir is gone, no roster.
|
||||
party_dir="$PARTY_SOCKET_DIR/party-$USER:noauth.d"
|
||||
[ ! -d "$party_dir" ]
|
||||
|
||||
# And we never widened the socket: chmod g+rw runs only after the
|
||||
# capability probe passes. apply_party_perms_file lives on the file
|
||||
# itself, so confirm the cmd stream stopped before that path.
|
||||
! grep -q "server-access -aw $USER" "$TMUX_LOG" || \
|
||||
fail "should not have attempted -aw before capability check"
|
||||
}
|
||||
45
tests/99-e2e.bats
Normal file
45
tests/99-e2e.bats
Normal file
|
|
@ -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
|
||||
}
|
||||
136
tests/helpers.bash
Normal file
136
tests/helpers.bash
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# Bats helpers shared by every test file.
|
||||
# `load 'helpers'` from a .bats file pulls these in.
|
||||
|
||||
# helpers.bash lives at <repo>/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"
|
||||
}
|
||||
161
tools/post-install.sh
Executable file
161
tools/post-install.sh
Executable file
|
|
@ -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 <install|uninstall> <prefix>
|
||||
#
|
||||
# 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 <install|uninstall> <prefix>" >&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 <<EOF
|
||||
$header
|
||||
For 'party' and 'man party' to work in fresh shells, add to your shell rc:
|
||||
|
||||
export PATH="$bd:\$PATH"
|
||||
export MANPATH="$md:\$MANPATH"
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
$header
|
||||
If you previously added these to your shell rc, remove them:
|
||||
|
||||
export PATH="$bd:\$PATH"
|
||||
export MANPATH="$md:\$MANPATH"
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Idempotently add ($verb=install) or remove ($verb=uninstall) $needle as a
|
||||
# component of the PATH= line in $file. Returns 0 in all expected cases —
|
||||
# only awk/IO failures bubble up as nonzero. Prints what it did.
|
||||
edit_path_line() {
|
||||
verb=$1 file=$2 needle=$3
|
||||
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "$file does not exist; skipping PATH wiring." >&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
|
||||
Loading…
Add table
Add a link
Reference in a new issue