#!/bin/bash

# Copyright 2018, 2019 eomanis
# 
# This file is part of inherit-acl.
#
# inherit-acl is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# inherit-acl is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with inherit-acl.  If not, see <http://www.gnu.org/licenses/>.

# Set shell options
set -o pipefail
set -o noclobber
set -o nounset
set -o errexit

# Globals
# The absolute paths to some programs so that the caller hopefully
# cannot spoof them
declare -r programSed="/usr/bin/sed"
declare -r programChown="/usr/bin/chown"
declare -r programChgrp="/usr/bin/chgrp"
declare -r programChmod="/usr/bin/chmod"
declare -r programGetfacl="/usr/bin/getfacl"
declare -r programSetfacl="/usr/bin/setfacl"
declare -r programRealpath="/usr/bin/realpath"

# getDefaultAcl directory
getDefaultAcl () {
	local directory="$1"; shift
	local defaultAcl
	
	# Read the directory's default ACL
	defaultAcl="$("$programGetfacl" --default --omit-header --no-effective --skip-base -- "$directory" 2> /dev/null)" || return 1
	if test "" = "$defaultAcl" ; then
		# No ACL present
		return 0
	fi
	# Echo the default ACLs in "regular ACL" notation, i.e. without "default:"
	# prepended
	echo "$defaultAcl"
	# Echo the default ACLs in "default ACL" notation, i.e. with "default:"
	# prepended
	echo "$defaultAcl" | "$programSed" -re 's/^(.+)$/default:\1/'
}

# isWhitelisted userLogin path
# 
# Returns with code 0 if the given path is whitelisted by the given
# user's parent directory whitelist
isWhitelisted () {
	local userLogin="$1"; shift
	local parentDir
	parentDir="$(echo "$1" | sed -re 's,/+$,,')"; shift
	local whitelistedParentDir
	
	# Return false immediately if the associative array does not contain
	# a mapping for the user login
	test -v parentDirWhitelist[$userLogin] || return 1
	
	# Return false immediately if the given path is the empty string
	# Since trailing slashes are stripped from the path, this also
	# denies root
	test "" = "$parentDir" && return 1
	
	# Walk upwards the given path's chain of parent directories and test
	# if any of them is among the user's whitelisted parent directories
	while parentDir="$("$programRealpath" --canonicalize-missing -- "${parentDir}/..")"; do
		while read -rs whitelistedParentDir; do
			test "" = "$whitelistedParentDir" && continue
			test "$whitelistedParentDir" = "$parentDir" && return 0
		done < <( echo "${parentDirWhitelist[$userLogin]}" )
		# Do not attempt to walk upwards beyond root
		test "$parentDir" = "/" && break
	done
	return 1
}

# No arguments: Print a help message
if test $# -eq 0; then
	echo -n \
"Usage:
inherit-acl-run userLogin path [path]...

For each given path, recursively applies the path's parent directory's
 - owning user
 - owning group
 - permissions
 - default ACL

Must be run as root and is supposed to be called from its companion
launcher application \"inherit-acl\"; the calling user's login name is
given as the 1st argument.

For more information run \"inherit-acl\" without arguments.
"
	exit 1
fi

# Make sure the required programs are available
type "$programSed" &> /dev/null      || { echo "ERROR  Required program \"${programSed}\" is not available" >&2;      exit 1; }
type "$programChown" &> /dev/null    || { echo "ERROR  Required program \"${programChown}\" is not available" >&2;    exit 1; }
type "$programChgrp" &> /dev/null    || { echo "ERROR  Required program \"${programChgrp}\" is not available" >&2;    exit 1; }
type "$programChmod" &> /dev/null    || { echo "ERROR  Required program \"${programChmod}\" is not available" >&2;    exit 1; }
type "$programGetfacl" &> /dev/null  || { echo "ERROR  Required program \"${programGetfacl}\" is not available" >&2;  exit 1; }
type "$programSetfacl" &> /dev/null  || { echo "ERROR  Required program \"${programSetfacl}\" is not available" >&2;  exit 1; }
type "$programRealpath" &> /dev/null || { echo "ERROR  Required program \"${programRealpath}\" is not available" >&2; exit 1; }

# Declare some more global variables
configFile="/etc/inherit-acl.conf"

# Set up the configuration
declare -A parentDirWhitelist
# Root may do anything
parentDirWhitelist["root"]='/'
# Source the configuration file if it is available
if test -f "$configFile"; then
	# shellcheck source=/dev/null
	source "$configFile"
else
	echo " INFO  Configuration file \"$configFile\" not found or not a file, only root may use this application" >&2
fi

# Read arguments
userLogin="$1"; shift

# Validate arguments
# The user login is only a lookup key for the parent directories
# whitelist map, so no further validation is required

if test "root" != "$userLogin"; then
	# Non-root user: Make sure hardlink protection is active
	protectedHardlinks="$(< "/proc/sys/fs/protected_hardlinks")"
	if test "1" != "$protectedHardlinks"; then
		echo "ERROR  Hardlink protection is not active on this system, only root may use this application" >&2
		exit 1
	fi
fi

# Process the given paths
declare -i exitCode=0
declare defaultAcl
while test $# -gt 0; do
	exitCode=0
	
	# Get the absolute canonicalized path of the current item
	# This also resolves any symlinks involved in the path
	item="$("$programRealpath" --canonicalize-missing -- "$1")"; shift
	
	# Some basic sanity checks
	if ! test -e "$item"; then
		echo " WARN  Target does not exist, skipping: \"$item\"" >&2
		exitCode=2
		continue
	fi
	
	# Test if the item is whitelisted for the user
	if ! isWhitelisted "$userLogin" "$item"; then
		echo " WARN  Path not whitelisted for user \"$userLogin\", skipping: \"$item\"" >&2
		exitCode=2
		continue
	fi
	
	# Get the absolute canonicalized path of the item's parent directory
	if ! parentDir="$("$programRealpath" --canonicalize-missing -- "${item}/..")" || test "" = "$parentDir"; then
		echo " WARN  Failed to determine the parent directory, skipping: \"$item\"" >&2
		exitCode=2
		continue
	fi
	
	# Apply the parent's user, group, permissions and ACL
	# If the parent does not have ACL, remove them from the item
	echo " INFO  Processing \"$item\"" >&2
	if ! "$programChown" --reference "$parentDir" --recursive --no-dereference -- "$item"; then
		echo " WARN  There were problems recursively setting the owning user" >&2
		exitCode=$(( exitCode | 4 ))
	fi
	if ! "$programChgrp" --reference "$parentDir" --recursive --no-dereference -- "$item"; then
		echo " WARN  There were problems recursively setting the owning group" >&2
		exitCode=$(( exitCode | 8 ))
	fi
	if ! "$programChmod" --reference "$parentDir" --recursive -- "$item"; then
		echo " WARN  There were problems recursively setting the permissions" >&2
		exitCode=$(( exitCode | 16 ))
	fi
	if defaultAcl="$(getDefaultAcl "$parentDir")"; then
		if test "$defaultAcl" != ""; then
			if ! echo "$defaultAcl" | "$programSetfacl" --recursive --physical --set-file - -- "$item"; then
				echo " WARN  There were problems recursively setting the ACL" >&2
				exitCode=$(( exitCode | 32 ))
			fi
		else
			if ! "$programSetfacl" --recursive --physical --remove-all -- "$item"; then
				echo " WARN  There were problems recursively removing the ACL" >&2
				exitCode=$(( exitCode | 32 ))
			fi
		fi
	else
		echo " WARN  There were problems reading the default ACL from parent directory \"${parentDir}\"" >&2
		exitCode=$(( exitCode | 32 ))
	fi
done
exit "$exitCode"