# Tiny Cloud - common script functions
# vim:set filetype=sh:
# shellcheck shell=sh

# set defaults
: "${ETC:=$ROOT/etc}"
: "${PROC:=$ROOT/proc}"
: "${SYS:=$ROOT/sys}"
[ -f "$ETC/tiny-cloud.conf" ] && . "$ETC/tiny-cloud.conf"
: "${CLOUD:=auto}"
: "${CLOUD_USER:=alpine}"
: "${TINY_CLOUD_IMDS_GROUP=root}"
: "${TINY_CLOUD_TMP:=${TMPDIR:-/tmp}}"
: "${TINY_CLOUD_LOGS:=$ROOT/var/log}"
: "${TINY_CLOUD_VAR:=$ROOT/var/lib/cloud}"
: "${TINY_CLOUD_RUN:=$ROOT/run/tiny-cloud}"

# A trailing slash must not move the sibling metadata lock inside the directory.
while [ "$TINY_CLOUD_RUN" != / ] && [ "${TINY_CLOUD_RUN%/}" != "$TINY_CLOUD_RUN" ]; do
	TINY_CLOUD_RUN=${TINY_CLOUD_RUN%/}
done
while [ "$TINY_CLOUD_VAR" != / ] && [ "${TINY_CLOUD_VAR%/}" != "$TINY_CLOUD_VAR" ]; do
	TINY_CLOUD_VAR=${TINY_CLOUD_VAR%/}
done

state_dirs_valid() {
	local directory state runtime
	for directory in "$TINY_CLOUD_VAR" "$TINY_CLOUD_RUN"; do
		case "$directory" in
			/) echo 'tiny-cloud: state directories must not be /' >&2; return 1;;
			/*) ;;
			*) echo 'tiny-cloud: state directories must be absolute' >&2; return 1;;
		esac
		case "/$directory/" in */../*|*/./*|*'
'*) echo "tiny-cloud: unsafe state directory: $directory" >&2; return 1;; esac
	done
	# Compare canonical separators so the lock cannot fall inside the state
	# directory and be removed by reset. These directories have different lives.
	state=$(printf '%s' "$TINY_CLOUD_VAR" | tr -s /)
	runtime=$(printf '%s' "$TINY_CLOUD_RUN" | tr -s /)
	case "$runtime/" in "$state/"*) echo 'tiny-cloud: state directories must not overlap' >&2; return 1;; esac
	case "$state/" in "$runtime/"*) echo 'tiny-cloud: state directories must not overlap' >&2; return 1;; esac
}

prepare_dirs() (
	state_dirs_valid || return
	umask 022
	# The log parent is shared; never apply a managed-directory policy to it.
	mkdir -p "$TINY_CLOUD_LOGS" || return
	make_dir "$TINY_CLOUD_VAR" && make_dir "$TINY_CLOUD_RUN"
)

# Creation helpers for caller-owned private temporary storage.
private_mkdir() (
	umask 077
	mkdir -p "$@"
)

private_tmpdir() (
	umask 077
	mktemp -d "$TINY_CLOUD_TMP/${1:-tiny-cloud}.XXXXXX"
)

log() {
	local level="$1"
	shift
	local facility="local7"
	local stderr init
	local tag=$(basename "$0")
	while true; do
		case "$1" in
			-i) init=1 ;;
			-f) facility="$2"; shift ;;
			-s) stderr=-s ;;
			-t) tag="$tag/$2"; shift ;;
			*) break ;;
		esac
		shift
	done
	[ -z "$DEBUG" ] && [ "$level" = debug ] && return

	[ -n "$init" ] && echo "$@" >&2
	logger $stderr -p "$facility.$level" -t "${tag}[$$]" -- "$@"
	case "$level" in
		crit|alert|emerg) exit 1 ;;
	esac
}

lower() { tr 'A-Z' 'a-z'; }

# Read the local interface MAC in the form used by most metadata providers.
nic_mac() {
	local mac
	mac=$(cat "$SYS/class/net/$1/address") || return
	[ -n "$mac" ] || return 1
	printf '%s\n' "$mac" | lower
}

word_kval() {
	# <key> [<file>] [<chars>] - <key> value(s) from stdin/<file> words
	#	replace output <chars> with spaces (suitable for "sub-kv" situations)
	cat "${2:--}" 2>/dev/null | xargs -n1 | grep "^$1=" | cut -d= -f2- | paste -sd' ' | tr "$3" ' '
}

cloud_alias() {
	case "$1" in
		ec2) echo aws;;
		gce) echo gcp;;
		nocloud-net) echo nocloud;;
		oracle) echo oci;;
		*) echo "$1";;
	esac
}

cloud_hint() {
	local F cloud
	# try kernel cmdline & DMI product serial
	for F in "$PROC/cmdline" "$SYS/class/dmi/id/product_serial"; do
		cloud=$(word_kval tinycloud "$F" : | word_kval cloud)
		[ -z "$cloud" ] && cloud=$(word_kval ds "$F" ';' | cut -d' ' -f1 | lower)
		[ -n "$cloud" ] && {
			cloud_alias "$cloud"
			return
		}
	done
	return 0
}

cloud_autodetect() {
	for i in "$LIBDIR"/tiny-cloud/cloud/*/autodetect; do
		if [ -x "$i" ]; then
			"$i" || :
		fi
	done | sort -n | cut -d' ' -f2 | head -n 1
}

cloud_resolve() {
	[ "$CLOUD" = auto ] || return 0
	# previously detected?
	CLOUD=$(cat "$TINY_CLOUD_VAR"/.autodetect 2>/dev/null) || {
		CLOUD=$(cloud_hint)
		[ -z "$CLOUD" ] && CLOUD=$(cloud_autodetect)
		if [ -z "$CLOUD" ] || [ ! -d "$LIBDIR/tiny-cloud/cloud/$CLOUD" ]; then
			log err -t autodetect "unable to determine cloud"
			CLOUD=unknown
		else
			log notice -t autodetect "choosing '$CLOUD' cloud"
			# Caching is optional for unprivileged metadata queries.
			if [ "$(id -u)" = 0 ]; then
				make_file "$TINY_CLOUD_VAR/.autodetect" &&
					printf "%s\n" "$CLOUD" > "$TINY_CLOUD_VAR/.autodetect"
			fi
		fi
	}
}

# usage: replace_word <search> <replace> <list>...
replace_word() {
	local search="$1" replace="$2"
	shift 2
	for word in "$@"; do
		if [ "$word" = "$search" ]; then
			echo "$replace"
		else
			echo "$word"
		fi
	done
}

# usage: insert_after <where> <what> <list>...
insert_before() {
	local search="$1" addition="$2"
	shift 2
	for i in "$@"; do
		if [ "$i" = "$search" ]; then
			echo "$addition"
		fi
		echo "$i"
	done
}

# usage: insert_after <where> <what> <list>...
insert_after() {
	local search="$1" addition="$2"
	shift 2
	for i in "$@"; do
		echo "$i"
		if [ "$i" = "$search" ]; then
			echo "$addition"
		fi
	done
}

# usage: add_once <file> <line-to-add>...
add_once() {
	local file="$1"
	shift
	for line; do
		if ! grep -x -F "$line" "$file" 2>/dev/null; then
			mkdir -p "${file%/*}"
			printf "%s\n" "$line" >> "$file"
		fi
	done
}

# Managed paths are shared by creation, checking, and repair. Keep shared system
# directories (notably TINY_CLOUD_LOGS itself) outside this inventory.
private_files() {
	printf '%s\n' \
		"$TINY_CLOUD_VAR/user-data" \
		"$TINY_CLOUD_VAR/cloud-config" \
		"$TINY_CLOUD_VAR/network-config" \
		"$TINY_CLOUD_VAR/meta-data" \
		"$TINY_CLOUD_VAR/vendor-data" \
		"$TINY_CLOUD_VAR/.bootstrap-complete" \
		"$TINY_CLOUD_VAR/.user-data-organized" \
		"$TINY_CLOUD_VAR/.sshd-enabled" \
		"$TINY_CLOUD_VAR/.macifrename-enabled" \
		"$TINY_CLOUD_VAR/.nocloud_loaded" \
		"$TINY_CLOUD_LOGS/tiny-cloud-scripts.log" \
		"$TINY_CLOUD_LOGS/tiny-cloud-scripts.exit" \
		"$TINY_CLOUD_RUN.lock"
}

private_dirs() {
	printf '%s\n' \
		"$TINY_CLOUD_VAR/scripts"
}

public_files() {
	printf '%s\n' \
		"$TINY_CLOUD_VAR/.autodetect" \
		"$TINY_CLOUD_VAR/.imds-endpoint" \
		"$TINY_CLOUD_RUN/selection"
}

public_dirs() {
	printf '%s\n' \
		"$TINY_CLOUD_VAR" \
		"$TINY_CLOUD_RUN"
}

metadata_dirs() {
	printf '%s\n' \
		"$TINY_CLOUD_RUN/metadata"
}

metadata_files() {
	local name
	for name in .selection meta_data.json user_data network_data.json vendor_data.json \
		vendor_data2.json meta-data.json user-data meta-data vendor-data network-config; do
		printf '%s\n' "$TINY_CLOUD_RUN/metadata/$name"
	done
}

# With an argument, recognize only that destination, including a new script.
# Without arguments, enumerate existing numeric script entries.
script_files() {
	local file name
	if [ -e "$TINY_CLOUD_VAR/scripts" ] || [ -L "$TINY_CLOUD_VAR/scripts" ]; then
		_perms_safe "$TINY_CLOUD_VAR/scripts" || return
	fi
	if [ "$#" -gt 0 ]; then
		[ "${1%/*}" = "$TINY_CLOUD_VAR/scripts" ] || return 0
		name=${1##*/}
		case "$name" in ''|*[!0-9]*) return 0;; esac
		printf '%s\n' "$1"
		return 0
	fi
	for file in "$TINY_CLOUD_VAR/scripts/"*; do
		name=${file##*/}
		case "$name" in ''|*[!0-9]*) continue;; esac
		printf '%s\n' "$file"
	done
}

# Print the expected type, numeric owner/group, and mode for a destination.
_perms_spec() {
	local path="$1" policy type paths group=0 mode
	for policy in private public metadata script; do
		for type in dirs files; do
			[ "$policy:$type" != script:dirs ] || continue
			paths=$("${policy}_${type}" "$path") || return
			printf '%s\n' "$paths" | grep -qxF -- "$path" || continue
			case "$policy:$type" in
				private:dirs|script:files) mode=700;;
				private:files) mode=600;;
				public:dirs) mode=755;;
				public:files) mode=644;;
				metadata:dirs) mode=750;;
				metadata:files) mode=640;;
			esac
			if [ "$policy" = metadata ]; then
				[ -n "$TINY_CLOUD_IMDS_GROUP" ] || break
				group=$(getent group "$TINY_CLOUD_IMDS_GROUP") || break
				group=$(printf '%s\n' "$group" | cut -d: -f3)
				case "$group" in ''|*[!0-9]*) break;; esac
			fi
			printf '%s 0 %s %s\n' "${type%s}" "$group" "$mode"
			return 0
		done
	done
	printf 'perms: no valid policy for %s\n' "$path" >&2
	return 1
}

# Validate ancestors too: checking only the final component follows symlinks in
# its parent path. Sticky shared ancestors such as /tmp are allowed.
_perms_safe() {
	local path="$1" target="$1" data uid mode links
	case "$path" in /*) ;; *) printf 'perms: expected absolute path: %s\n' "$path" >&2; return 1;; esac
	case "/$path/" in */../*|*/./*|*'
'*) printf 'perms: unsafe path: %s\n' "$path" >&2; return 1;; esac
	while :; do
		if [ -L "$path" ]; then
			printf 'perms: refusing symlink: %s\n' "$path" >&2
			return 1
		fi
		data=$(stat -c '%u %a %h' "$path") || return
		read -r uid mode links <<-EOT
			$data
		EOT
		if [ "$uid" != 0 ] || { [ "$path" = "$target" ] && [ -f "$path" ] && [ "$links" != 1 ]; }; then
			printf 'perms: unsafe ownership or links: %s\n' "$path" >&2
			return 1
		fi
		if [ "$((0$mode & 0022))" -ne 0 ]; then
			if [ "$path" = "$target" ] || [ "$((0$mode & 01000))" -eq 0 ]; then
				printf 'perms: refusing writable path: %s\n' "$path" >&2
				return 1
			fi
		fi
		[ "$path" != / ] || break
		path=${path%/*}
		[ -n "$path" ] || path=/
	done
}

# A staged directory's destination need not exist yet. Validate the nearest
# existing ancestor; any symlink, even a dangling one, stops this walk.
_perms_parent() {
	local parent="${1%/*}"
	case "$1" in /*) ;; *) printf 'perms: expected absolute path: %s\n' "$1" >&2; return 1;; esac
	while [ ! -e "$parent" ] && [ ! -L "$parent" ]; do
		parent=${parent%/*}
		[ -n "$parent" ] || parent=/
	done
	_perms_safe "$parent"
}

perms() {
	local action="$1" path="$2" target="${3:-$2}" spec actual type uid gid mode
	case "$action" in check|set) ;; *) printf 'perms: expected check or set\n' >&2; return 1;; esac
	spec=$(_perms_spec "$target") || return
	_perms_safe "$path" || return
	if [ "$target" != "$path" ]; then
		_perms_parent "$target" || return
		if [ -e "$target" ] || [ -L "$target" ]; then
			_perms_safe "$target" || return
		fi
	fi
	read -r type uid gid mode <<-EOT
		$spec
	EOT
	case "$type" in
		dir) [ -d "$path" ] || { printf 'perms: expected directory: %s\n' "$path" >&2; return 1; };;
		file) [ -f "$path" ] || { printf 'perms: expected regular file: %s\n' "$path" >&2; return 1; };;
	esac
	actual=$(stat -c '%u %g %a' "$path") || return
	[ "$actual" != "$uid $gid $mode" ] || return 0
	if [ "$action" = check ]; then
		printf 'perms: %s: expected %s:%s %s; found %s\n' "$path" "$uid" "$gid" "$mode" "$actual" >&2
		return 1
	fi
	[ "$(id -u)" = 0 ] || { printf 'perms: repair requires root\n' >&2; return 1; }
	chown "$uid:$gid" "$path" && chmod "$mode" "$path"
}

# Secure creation is opt-in for managed state; unknown destinations fail before
# any file is created. Staged content uses perms set "$stage" "$destination".
make_file() (
	local path="$1" spec
	spec=$(_perms_spec "$path") || return
	[ "${spec%% *}" = file ] || return 1
	if [ -e "$path" ] || [ -L "$path" ]; then
		perms set "$path" || return
	else
		_perms_safe "${path%/*}" || return
		umask 077
		# Another query may have created the same lock or hint meanwhile.
		(set -C; : > "$path") 2>/dev/null || {
			[ -e "$path" ] || [ -L "$path" ] || return 1
		}
		perms set "$path"
	fi
)

make_dir() (
	local path="$1" spec
	spec=$(_perms_spec "$path") || return
	[ "${spec%% *}" = dir ] || return 1
	if [ -e "$path" ] || [ -L "$path" ]; then
		perms set "$path"
	else
		_perms_parent "$path" || return
		# Newly needed ancestors must permit traversal, like mkdir -p's normal
		# system-directory defaults. Existing parents are never chmodded.
		(umask 022; mkdir -p "${path%/*}") || return
		private_mkdir "$path" && perms set "$path"
	fi
)

walk_perms() (
	state_dirs_valid || return
	local action="$1" policy type path paths rv=0
	[ "$action" = check ] || [ "$action" = fix ] || return 1
	if [ "$action" = fix ] && [ "$(id -u)" != 0 ]; then
		printf 'perms: repair requires root\n' >&2
		return 1
	fi
	# Never enumerate through an unsafe container.
	for path in "$TINY_CLOUD_VAR" "$TINY_CLOUD_RUN"; do
		if [ -e "$path" ] || [ -L "$path" ]; then
			_perms_safe "$path" || return
			[ -d "$path" ] || return 1
		fi
	done
	for policy in public private metadata script; do
		for type in dirs files; do
			[ "$policy:$type" != script:dirs ] || continue
			paths=$("${policy}_${type}") || return
			while IFS= read -r path; do
				[ -e "$path" ] || [ -L "$path" ] || continue
				if [ "$action" = check ]; then
					perms check "$path" || rv=1
				else
					if ! perms check "$path" 2>/dev/null; then
						perms set "$path" && printf 'perms: fixed %s\n' "$path" || rv=1
					fi
				fi
			done <<-EOT
				$paths
			EOT
		done
	done
	for path in "$TINY_CLOUD_VAR" "$TINY_CLOUD_RUN"; do
		[ -d "$path" ] || continue
		_perms_scan "$path" || rv=1
	done
	exit "$rv"
)

_perms_scan() (
	local directory="$1" paths path rv=0
	_perms_safe "$directory" || return
	paths=$(find "$directory" -mindepth 1 -maxdepth 1 -print) || return
	while IFS= read -r path; do
		[ -n "$path" ] || continue
		if ! _perms_spec "$path" >/dev/null 2>&1; then
			printf 'perms: unknown path (unchanged): %s\n' "$path" >&2
			rv=1
		elif [ -d "$path" ]; then
			_perms_scan "$path" || rv=1
		fi
	done <<-EOT
		$paths
	EOT
	exit "$rv"
)

# The lock lives beside the runtime directory so reset cannot unlink an inode
# still held by a publisher. Readers of completed snapshots do not take it.
lock_metadata() {
	local attempts=0
	make_file "$TINY_CLOUD_RUN.lock" || return
	exec 8<>"$TINY_CLOUD_RUN.lock" || return
	while ! flock -n 8; do
		attempts=$((attempts + 1))
		[ "$attempts" -lt 300 ] || { echo 'imds: timed out waiting for metadata state' >&2; return 1; }
		sleep 0.1
	done
}
