#!/bin/sh
# wpchroot-owner <wp-user|www-user> <docroot>
#
# Switches the owner of the WordPress areas of a docroot to the WP_USER or the WWW_USER account
# of /etc/wpchroot.conf (the GROUP always stays WWW_USER).
#   WP_USER:  chown -R WP_USER:WWW_USER of ALL of <docroot> -> the wpchroot commands can rm and
#             overwrite everything (uploads/cache being normally www:www, only root can switch
#             them -> this runs AS ROOT).
#   WWW_USER: chown -R WWW_USER:WWW_USER of wp-content/uploads and wp-content/cache ONLY, and the
#             mode of those two directories themselves (not their contents) set to AREA_MODE; the
#             code stays WP_USER:WWW_USER. The areas are created by WP_USER (they live in its
#             wp-content), never by this command.
#
# Run AS ROOT through doas, from WP_USER (wpchroot restore) or from ansible. The doas rule carries
# no `args` clause, so <docroot> is the only thing between WP_USER and a chown -R of any tree:
# only the two forms below are accepted, built from WWW of the configuration
# (SITES_ROOT is $WWW/sites and HTDOCS_SHARED is $WWW/htdocs, wpchroot checks it):
#   $WWW/sites/<site>/htdocs   isolated site (own php-fpm chroot)
#   $WWW/htdocs/<site>         shared pool
# <site> is one path component spelled like a host name. Every component from $WWW down to each
# area chowned (wp-content, uploads, cache included) is refused when it is a symbolic link, and so
# is a path that does not resolve to itself. Inside the tree, chown -R -P changes the links
# themselves and never crosses one [verifie:man.openbsd.org/chown.8, -P; openbsd/src
# bin/chmod/chmod.c, FTS_PHYSICAL and AT_SYMLINK_NOFOLLOW]. Residual window: see the notes at the
# act below and wpchroot(8), SECURITY.
#
# A well-formed path is not enough: the docroot must also be a WordPress site of WP_USER, in both
# directions, or the forms would let code running as WP_USER take any other tree of the host whose
# docroot has this shape (a package repository, a static site). The marker is a wp-config.php, a
# regular file (not a link) owned by WP_USER:
#   $WWW/sites/<site>/wp-config.php   isolated site, in the PARENT of htdocs, where the role and the
#                                     restore put it, so it also exists while htdocs is empty
#   $WWW/htdocs/<site>/wp-config.php  shared pool, or wp-load.php of the same owner (a restore
#                                     removes the wp-config.php of the docroot and keeps the core)
# WP_USER cannot create a file in a directory it does not write, so the marker cannot be forged
# where the account has no hold already.
#
# Configuration: only WWW, WP_USER and WWW_USER are read, and only from a regular file owned by
# root:wheel that group and others cannot write. No environment variable changes what is read.
#
# rc: 0 = OK, 2 = usage, refused docroot (no marker included), a link on the way or a hard link in
# an area (one line on stderr), 4 = bad configuration, 5 = a component turned into a link during the run (reported, not
# undone), 1 = nodes or area modes still out of shape after the pass. A chown error surfaces
# through set -e. Prints the whole line "changed" on stdout when a chown or a chmod happened
# (-> ansible: changed_when).
set -eu
# ksh imports the caller's PATH over its default and never restores it
# [verifie:openbsd/src bin/ksh/main.c:272-274].
PATH=/bin:/sbin:/usr/bin:/usr/sbin
CONF=/etc/wpchroot.conf
CONF_IDS='0 0'
LOGGER=/usr/bin/logger

me=${0##*/}
doc=${2:-}

usage() { echo "usage: $me <wp-user|www-user> <docroot>" >&2; exit 2; }
refuse() { echo "$me: refused docroot $doc: $1" >&2; exit 2; }
conf_fail() { echo "$me: $CONF: $1" >&2; exit 4; }

# The configuration file must be root's and closed to group and others before any byte of it
# decides a path. ls -ldn: "-" first for a regular file (a link shows "l"), then the numeric
# uid and gid; mode character 6 is group write, 9 is other write.
conf_check() {
	[ -f "$CONF" ] || conf_fail "not found"
	# shellcheck disable=SC2046
	set -- $(ls -ldn "$CONF")
	case $1 in -*) ;; *) conf_fail "not a regular file" ;; esac
	[ "$3 $4" = "$CONF_IDS" ] || conf_fail "must be owned by root:wheel"
	case $1 in -????[!w]??[!w]*) ;; *) conf_fail "writable by group or others" ;; esac
}

# conf_get KEY: the last KEY=value line, optional matching quotes removed, into _V. No expansion
# is ever done: a value holds portable path characters only.
conf_get() {
	_V=''
	_found=0
	while IFS= read -r _l || [ -n "$_l" ]; do
		case $_l in "$1="*) _V=${_l#*=}; _found=1 ;; esac
	done < "$CONF"
	[ "$_found" = 1 ] || conf_fail "$1 is not set"
	case $_V in
		\"*\") _V=${_V#\"}; _V=${_V%\"} ;;
		\'*\') _V=${_V#\'}; _V=${_V%\'} ;;
	esac
	case $_V in
		''|*[!A-Za-z0-9._/+:@%-]*) conf_fail "$1 is empty or holds an unsafe character" ;;
	esac
}

[ $# -eq 2 ] || usage
owner=${1:-}
[ -n "$doc" ] || usage

conf_check
conf_get WWW
WWW=$_V
case $WWW in
	/*) ;;
	*) conf_fail "WWW is not an absolute path" ;;
esac
case $WWW in
	*/|*/../*|*/..|*//*) conf_fail "WWW is not a plain path" ;;
esac
conf_get WP_USER
WP_USER=$_V
conf_get WWW_USER
WWW_USER=$_V
for _n in "$WP_USER" "$WWW_USER"; do
	case $_n in -*|.*) conf_fail "an account name starts with a dash or a dot" ;; esac
done
[ "$WP_USER" != "$WWW_USER" ] || conf_fail "WP_USER and WWW_USER are the same account"

case "$owner" in
	"$WP_USER"|"$WWW_USER") ;;
	*) usage ;;
esac

# Lexical form first: absolute, one slash between components, no `.` or `..` component, no newline
# (the patterns below let `*` span slashes, and comps is word-split).
nl='
'
case "$doc" in
	/*) ;;
	*) refuse "not an absolute path" ;;
esac
case "$doc" in
	*//*|*/./*|*/../*|*/.|*/..|*"$nl"*) refuse "not a plain path" ;;
esac

# The two docroot forms, and the components the symlink check walks.
case "$doc" in
	"$WWW"/sites/*/htdocs)
		_t=${doc#"$WWW"/sites/}
		site=${_t%/htdocs}
		comps="$WWW $WWW/sites $WWW/sites/$site $doc"
		markers="$WWW/sites/$site/wp-config.php"
		;;
	"$WWW"/htdocs/*)
		site=${doc#"$WWW"/htdocs/}
		comps="$WWW $WWW/htdocs $doc"
		markers="$doc/wp-config.php $doc/wp-load.php"
		;;
	*) refuse "outside $WWW/sites/<site>/htdocs and $WWW/htdocs/<site>" ;;
esac
# One component, host name characters only: no `/`, no `..`, no leading dot or dash, no
# whitespace or glob character (comps is word-split below).
case "$site" in
	''|.*|-*|*[!A-Za-z0-9.-]*) refuse "site is not a host name" ;;
esac
for _p in $comps; do
	[ -L "$_p" ] && refuse "$_p is a symbolic link"
done
[ -d "$doc" ] || refuse "not a directory"

# Areas, depending on the direction.
if [ "$owner" = "$WP_USER" ]; then
	set -- "$doc"                                            # all of htdocs
else
	set -- "$doc/wp-content/uploads" "$doc/wp-content/cache" # uploads + cache
fi

# Every component from $doc down to an area is checked, not the last one alone: WP_USER owns the
# docroot and can swap wp-content (or a parent of an area) for a symbolic link to any tree, which a
# path-based chown would walk through. chain_check refuses a link at any level; phys_check refuses
# a path whose physical form (cd -P, pwd -P) differs from the lexical one under the physical $WWW,
# which catches a link at any level whatever the test for it missed.
chain_find() {   # $1 = a path under $doc, or $doc itself: _BAD = the first link on the way, or empty
	_BAD=''
	_acc=$doc
	_rest=${1#"$doc"}
	_rest=${_rest#/}
	while [ -n "$_rest" ]; do
		_c=${_rest%%/*}
		case $_rest in
			*/*) _rest=${_rest#*/} ;;
			*) _rest='' ;;
		esac
		_acc=$_acc/$_c
		if [ -L "$_acc" ]; then _BAD=$_acc; return 0; fi
	done
	return 0
}
chain_check() {
	chain_find "$1"
	[ -z "$_BAD" ] || refuse "$_BAD is a symbolic link"
	return 0
}
WWWP=$(cd -P "$WWW" && pwd -P)
phys_check() {   # $1 = an existing path under $WWW
	_x=$(cd -P "$1" 2>/dev/null && pwd -P) || _x=''
	[ "$_x" = "$WWWP${1#"$WWW"}" ] || refuse "$1 does not resolve to itself"
	return 0
}
phys_check "$doc"

# The WordPress marker, in both directions, before anything is chowned. The directory that holds it
# is a component of the chain checked above (the site directory, or the docroot itself), so only the
# file is left to judge: find does not follow a link unless asked to, and -type f is then false for
# one [verifie:man.openbsd.org/find.1, -H and -L describe the exceptions, -type f, -user, -prune].
# The substitution sits inside a test, set -e does not stop on a missing path.
marker_ok() {   # $1 = file: a regular file, not a link, owned by WP_USER
	[ -n "$(find "$1" -prune -type f -user "$WP_USER" -print 2>/dev/null)" ]
}
_ok=0
for _m in $markers; do
	if marker_ok "$_m"; then _ok=1; break; fi
done
[ "$_ok" = 1 ] || refuse "not a WordPress site of $WP_USER, no regular file owned by it at: $markers"

for z in "$@"; do
	chain_check "$z"
	[ -d "$z" ] && phys_check "$z"
done

# A hard link planted in an area names a node that lives elsewhere too (code, another site's
# file): chown -R would hand it to <owner>. link(2) checks no ownership, only search on the
# source path and write on the target directory [verifie:openbsd/src sys/kern/vfs_syscalls.c,
# dolinkat; sys/ufs/ufs/ufs_vnops.c, ufs_link]. Any non-directory with more than one link refuses
# the run, before the first chown [verifie:man.openbsd.org/find.1, -links n, -type, !]. Output
# captured whole: set -e stops on a find that fails.
for z in "$@"; do
	if [ -d "$z" ]; then
		_h=$(find "$z" ! -type d -links +1 -print)
		[ -z "$_h" ] || refuse "$(printf '%s\n' "$_h" | head -1) has more than one link"
	fi
done

# Idempotent: an area is chowned ONLY when a node does not already have the expected owner:group.
differs() {   # $1 = area
	[ -n "$(find "$1" \( ! -user "$owner" -o ! -group "$WWW_USER" \) -print 2>/dev/null | head -1)" ]
}
# The mode of an area directory itself, WWW_USER direction only: -prune keeps find on the start
# path, -perm compares bits 07777 exactly [verifie:man.openbsd.org/find.1, -perm, -prune].
AREA_MODE=0770
mode_differs() {   # $1 = area
	[ -n "$(find "$1" -prune -type d ! -perm "$AREA_MODE" -print 2>/dev/null)" ]
}
todo=''
modetodo=''
for z in "$@"; do
	[ -d "$z" ] || continue
	if differs "$z"; then todo="$todo $z"; fi
	if [ "$owner" != "$WP_USER" ] && mode_differs "$z"; then modetodo="$modetodo $z"; fi
done

# Acting. chown -R -P: no symbolic link is followed, the command line included
# [verifie:man.openbsd.org/chown.8, -P]; under -R the tree is walked by fts with a chdir into each
# directory, the node changed by its bare name after dev and inode were compared, and a link met in
# the tree is changed itself [verifie:openbsd/src bin/chmod/chmod.c, FTS_PHYSICAL, AT_SYMLINK_NOFOLLOW,
# fchownat(AT_FDCWD, fts_accpath); lib/libc/gen/fts.c, fts_safe_changedir]. A directory swapped for
# a link inside the tree after the walk entered it is thus never crossed.
# The only path-based resolution left is the path handed to chown. The whole-docroot direction
# hands $doc, whose parents are checked above. The uploads/cache direction must not hand
# $doc/wp-content/uploads to chown: wp-content belongs to WP_USER and a swap between the check and
# the chown would redirect it. find walks from $doc instead, physically (a link is not entered),
# prunes everything but wp-content and the area, and -execdir runs chown from the directory
# that holds the area, on its bare name, after the same dev/ino check
# [verifie:man.openbsd.org/find.1, -execdir, -path, -prune; openbsd/src lib/libc/gen/fts.c,
# fts_safe_changedir]. A failed -execdir only makes its primary false, find exits 0: the areas are
# read again below. The mode goes the same way, chmod -h acting on a link itself, never on what it
# points to [verifie:man.openbsd.org/chmod.1, -h; the same form as wpchroot-perms].
changed=0
for z in $todo; do
	if [ "$owner" = "$WP_USER" ]; then
		chown -R -P "$owner":"$WWW_USER" "$z"
	else
		find "$doc" \( -path "$z" -prune -execdir chown -R -P "$owner":"$WWW_USER" {} \; \) -o \
			\( -path "$doc" -o -path "$doc/wp-content" \) -o -prune
	fi
	changed=1
done
for z in $modetodo; do
	find "$doc" \( -path "$z" -prune -execdir chmod -h "$AREA_MODE" {} \; \) -o \
		\( -path "$doc" -o -path "$doc/wp-content" \) -o -prune
	changed=1
done

# After: no component may have turned into a link meanwhile (a swap below $doc cannot redirect the
# act, see above; one above it, if its parent is writable by WP_USER, can, and is reported here, not
# undone). Looked at BEFORE the shape of the nodes: a shape read through a swapped link describes
# the target, not the area.
post_bad=''
for _p in $comps; do
	if [ -L "$_p" ]; then post_bad=$_p; fi
done
for z in "$@"; do
	chain_find "$z"
	if [ -n "$_BAD" ]; then post_bad=$_BAD; fi
done
if [ -n "$post_bad" ]; then
	"$LOGGER" -t wpchroot -- "owner $owner: $post_bad became a symbolic link during the run" 2>/dev/null || true
	echo "$me: $doc: $post_bad became a symbolic link during the run" >&2
	exit 5
fi
for z in $todo; do
	if differs "$z"; then
		echo "$me: $doc: $z still out of shape after the pass" >&2
		exit 1
	fi
done
for z in $modetodo; do
	if mode_differs "$z"; then
		echo "$me: $doc: $z mode still out of shape after the pass" >&2
		exit 1
	fi
done

if [ "$changed" = 1 ]; then
	"$LOGGER" -t wpchroot -- "owner $owner: $doc" 2>/dev/null || true
	echo changed
fi
exit 0
