#!/bin/sh
# wpchroot-perms <docroot> <owner> <group> <dir_mode> <file_mode>
#
# Code of a WordPress docroot, read-only for www: every node <owner>:<group>, every directory
# <dir_mode>, every regular file <file_mode>. wp-content/uploads and wp-content/cache are pruned,
# the two directories included: www writes there, and wpchroot-owner sets their owner and mode.
#
# Reads the tree first and acts only on the nodes that differ (find -user, -group, -perm): a
# docroot already in shape costs one find, no chown and no chmod.
#
# Run AS ROOT (ansible, or `wpchroot perms`), on a tree WP_USER owns and writes. Same two docroot
# forms and same symlink check as wpchroot-owner, built from WWW of /etc/wpchroot.conf, the only
# key read, and only from a regular file owned by root:wheel that group and others cannot write.
# Inside the tree no link is followed: find walks it physically [verifie:openbsd/src
# usr.bin/find/main.c, FTS_PHYSICAL]; -execdir runs the utility on the bare name, from the
# directory fts entered after checking its device and inode [verifie:openbsd/src
# usr.bin/find/function.c, f_execdir; lib/libc/gen/fts.c, fts_safe_changedir]; -h makes chown and
# chmod act on a link, never on its target [verifie:man.openbsd.org/chown.8,
# man.openbsd.org/chmod.1]. A node WP_USER swaps for a link between the read and the act is thus
# changed itself, not what it points to.
#
# rc: 0 = OK, 2 = usage, refused docroot or a hard link in the code, 4 = bad configuration,
# 1 = nodes still out of shape after the pass; one line on stderr for 1, 2 and 4. Prints the whole
# line "changed" when it fixed a node (-> 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=${1:-}
owner=${2:-}
group=${3:-}
dmode=${4:-}
fmode=${5:-}

usage() { echo "usage: $me <docroot> <owner> <group> <dir_mode> <file_mode>" >&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 5 ] || usage
# Account names: portable characters, no leading dash (read as an option by find and chown).
for _n in "$owner" "$group"; do
	case "$_n" in ''|-*|*[!A-Za-z0-9._-]*) usage ;; esac
done
# Octal modes, the form find -perm compares exactly on bits 07777 and chmod sets
# [verifie:man.openbsd.org/find.1, -perm].
for _m in "$dmode" "$fmode"; do
	case "$_m" in [0-7][0-7][0-7]|[0-7][0-7][0-7][0-7]) ;; *) usage ;; esac
done

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

# 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"
		;;
	"$WWW"/htdocs/*)
		site=${doc#"$WWW"/htdocs/}
		comps="$WWW $WWW/htdocs $doc"
		;;
	*) 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"
cd "$doc"

# A hard link WP_USER plants in the code names a node that lives elsewhere too (a file of root or
# of another site on the same file system): chown and chmod would act on that node. link(2)
# checks no ownership [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 any change, and the passes below skip such a node besides
# [verifie:man.openbsd.org/find.1, -links n, -links +n].
_h=$(find . \( -path ./wp-content/uploads -o -path ./wp-content/cache \) -prune -o \
	! -type d -links +1 -print)
[ -z "$_h" ] || refuse "$(printf '%s\n' "$_h" | head -1) has more than one link"

# Nodes out of shape, pruned areas left out. Whole output captured, never piped to head: find
# exits 1 on an unknown owner or group [verifie:openbsd/src usr.bin/find/function.c, c_user,
# c_group], and set -e stops on that rc where a pipe would read silence.
bad() {
	find . \( -path ./wp-content/uploads -o -path ./wp-content/cache \) -prune -o \
		\( ! -user "$owner" -o ! -group "$group" -o \
		\( -type d ! -perm "$dmode" \) -o \( -type f ! -perm "$fmode" \) \) -print
}

_bad=$(bad)
[ -z "$_bad" ] && exit 0

find . \( -path ./wp-content/uploads -o -path ./wp-content/cache \) -prune -o \
	\( ! -user "$owner" -o ! -group "$group" \) \( -type d -o -links 1 \) \
	-execdir chown -h "$owner:$group" {} \;
find . \( -path ./wp-content/uploads -o -path ./wp-content/cache \) -prune -o \
	-type d ! -perm "$dmode" -execdir chmod -h "$dmode" {} \;
find . \( -path ./wp-content/uploads -o -path ./wp-content/cache \) -prune -o \
	-type f -links 1 ! -perm "$fmode" -execdir chmod -h "$fmode" {} \;

# A failed -execdir only makes its primary false, find still exits 0 [verifie:openbsd/src
# usr.bin/find/function.c, f_execdir; find.c, find_execute]: the tree is read again to know.
_bad=$(bad)
if [ -n "$_bad" ]; then
	nl='
'
	echo "$me: $doc: still out of shape after the pass: ${_bad%%"$nl"*}" >&2
	exit 1
fi
"$LOGGER" -t wpchroot -- "perms $owner:$group $doc" 2>/dev/null || true
echo changed
exit 0
