#!/bin/sh
# SPDX-License-Identifier: MIT

LIST=""
RECURSE=""
REMOVE=""
REVERT=""
PRESERVE=""
VERBOSE=""
APK=""
COMMAND=""

print_help() {
	echo "Usage: $0 [options] file [more files...]"
	echo
	echo "Mode selection:"
	echo "  (none)         single entry copy"
	echo "  -d, --delete   delete file"
	echo "  -f, --force    force operation: ignore non-existing files"
	echo "  -l, --list     list content of overlay"
	echo "  -a, --apk      apk mode: pass any argument after that to apk on rootfs"
	echo "  -R, --revert   revert change: only delete from overlay, making it"
	echo "                 look like the file was reverted back to original state"
	echo "  -c, --command  run arbitrary command with /target mounted rw"
	echo
	echo "Copy options:"
	echo "  -r, --recurse  recursive copy (note this also removes files!)"
	echo "  -p, --preserve make the copy persist through baseos upgrade"
	echo "                 by adding entries to /etc/swupdate_preserve_files"
	echo "  -P, --preserve-post   same, but copy after upgrade (POST)"
	echo
	echo "Delete options:"
	echo "  -r, --recurse  recursively delete files"
	echo
	echo "Common options:"
	echo "  -v, --verbose  verbose mode for all underlying commands"
	echo
	echo "Note this directly manipulates overlayfs lower directories"
	echo "so might need a reboot to take effect"
}

error() {
	printf "%s\n" "$@" >&2
	exit 1
}

check_overlay() {
	[ "$(findmnt -nr -o FSTYPE /)" = "overlay" ]
}

exists() {
	# -e fails for dead links, but we care about dentry presence
	# so also accept if it is a symlink
	[ -e "$1" ] || [ -L "$1" ]
}

run_command() {
	if [ "$#" = 0 ]; then
		print_help
		error "Nothing to do"
	fi
	if ! check_overlay; then
		unshare -m sh -e -c 'mount --bind / /target
				mount -o remount,rw - /target
				exec "$@"' -- "$@"
		rc=$?
		sync
		return $rc
	fi
	# shellcheck disable=SC2016 ## yes, single quotes don't expand variables..
	unshare -m sh -e -c 'mount --bind /live/rootfs /target
			mount -o remount,rw - /target
			old=$(cat /target/etc/.rootfs_update_timestamp 2>/dev/null)
			date +%s > /target/etc/.rootfs_update_timestamp
			if echo "$old" | cmp -s - /target/etc/.rootfs_update_timestamp; then
				echo "(differentiator for identical timestamps)" >> /target/etc/.rootfs_update_timestamp
			fi
			exec "$@"' -- "$@"
	rc=$?
	sync
	return $rc
}

apk() {
	run_command apk -p /target "$@"

	# clear in ram apk db if present and drop cache:
	# this increases chances of package working right away
	rm -rf /live/overlay_upper/lib/apk/db
	echo 3 > /proc/sys/vm/drop_caches

	if stty >/dev/null 2>&1; then
		echo "Install succeeded, but might not work in the running system" >&2
		echo "Please reboot if installed program does not work" >&2
	fi
}

is_whiteout() {
	local file="$1"
	[ -c "$file" ] && [ "$(stat -c %t,%T "$file")" = "0,0" ]
}
list_one() {
	local file="$1"
	local base="${file#/live/overlay_upper}"
	local filetype

	if is_whiteout "$file"; then
		filetype=whiteout
	elif [ -d "$file" ] &&
	    [ "$(getfattr -n trusted.overlay.opaque --only-values "$file" 2>/dev/null)" = y ]; then
		filetype="opaque directory"
	else
		# ignore any file we cannot stat (removed concurrently?)
		# and complete listing
		filetype=$(stat -c "%F" "$file" 2>/dev/null) \
			|| return 0
	fi

	printf "%-18s %s\n" "$filetype" "$base"
}

list() {
	local file _file failed=""

	# normalize arguments
	for _file in "$@"; do
		file="$_file"
		if [ "${file#/}" = "$file" ]; then
			# make relative path absolute
			file="$PWD/$file"
		fi
		shift
		if ! exists "/live/overlay_upper$file"; then
			failed=1
			echo "$_file not found in overlay, skipping" >&2
			continue
		fi
		set -- "$@" "$file"
	done
	if [ "$#" -eq 0 ]; then
		[ -z "$failed" ] || error "Could not find any files requested"
		set -- /
	fi
	for file in "$@"; do
		# shellcheck disable=SC3045,SC2030 # read -d, file modified in a subshell
		find "/live/overlay_upper$file" -print0 |
			while read -d "" -r file; do
				list_one "$file"
			done
	done
}

# shellcheck disable=SC2031 # file modified in list() subshell
simplify_file() {
	local dotdot=0 orig_file
	while [ "$file" != "$orig_file" ]; do
		orig_file="$file"
		# remove tailing slashes
		if [ "${file%/}" != "$file" ]; then
			file="${file%/}"
			continue
		fi
		# - we cannot leave '/.' last because busybox rm refuses it
		if [ "${file%/.}" != "$file" ]; then
			file="${file%/.}"
			continue
		fi
		# - we cannot leave '/..' last because we will remove the child
		# dir in recursive copy case and that will not work, so try to
		# simplify these
		if [ "${file%/..}" != "$file" ]; then
			file="${file%/..}"
			dotdot=$((dotdot+1))
			continue
		fi
		# - remove as many components as required by '..'s
		while [ "$dotdot" -gt 0 ]; do
			file="${file%/*}"
			dotdot=$((dotdot-1))
		done
	done
	# if nothing is left, we were apparently pointing to / (/.. -> /)
	[ -n "$file" ] || file="/"
}

copy() {
	local file _file base
	local sh_opt=""
	local notfound=""

	if [ "$#" = 0 ]; then
		print_help
		error "Nothing to do"
	fi
	if ! check_overlay; then
		# nothing to do
		return 0
	fi

	# normalize arguments
	for _file in "$@"; do
		file="$_file"
		# normalize path:
		# - empty path is not valid
		if [ -z "$file" ]; then
			echo "Skipping empty argument" >&2
			shift
			continue
		fi
		# - we cannot use realpath because file might no longer exist
		if [ "${file#/}" = "$file" ]; then
			file="$PWD/$file"
		fi
		simplify_file

		shift
		if exists "/live/rootfs/$file" || exists "/live/overlay_upper$file"; then
			set -- "$@" "$file"
		elif exists "$file"; then
			# not in rootfs, drop from arguments, but don't error.
			# For delete case remove now
			if [ -n "$REMOVE" ]; then
				rm -f $RECURSE $VERBOSE -- "$file" \
					|| error "Could not remove $file"
			fi
		else
			[ -n "$FORCE" ] && continue
			notfound="$notfound $_file"
		fi
	done

	# files not in overlay requested
	[ "$#" = 0 ] && [ -z "$notfound" ] && return
	# non-existing files requested
	[ "$#" -gt 0 ] || error "Nothing to do, none of the requested files exist:$notfound"
	[ -z "$notfound" ] || echo "Warning: some files do not exist and were skipped:$notfound"

	# "unmark" rollback-clone rollback status: we're installing
	# something so this would be a downgrade
	if fw_printenv upgrade_available 2>/dev/null | grep -qx "upgrade_available=2"; then
		# we also need to change the flag on the other side... best effort:
		# we set it to 0 as we do not know if this side is bootable or not.
		# (fw_setenv config cannot be parsed from stdin)
		local tmpconf
		if tmpconf=$(mktemp /tmp/fw_env.other.XXXXXX 2>/dev/null); then
			# swap boot0/boot1
			sed -e 's/boot1/bootX/' -e 's/boot0/boot1/' -e 's/bootX/boot0/' \
					/etc/fw_env.config > "$tmpconf" \
				&& fw_setenv -c "$tmpconf" upgrade_available 0 >/dev/null 2>&1
			rm -f "$tmpconf"
		fi

		fw_setenv upgrade_available 1 > /dev/null \
			|| error "Could not modify upgrade_available"
	fi

	# debug: transmit -x down subshell
	case "$-" in
	*x*) sh_opt="-x";;
	esac

	# shellcheck disable=SC2016 # gets confused with variable expansion
	# shellcheck disable=SC1004 # backslashes at end of lines are ok...
	unshare -m sh $sh_opt -c '
		VERBOSE="'"$VERBOSE"'"
		RECURSE="'"$RECURSE"'"
		REMOVE="'"$REMOVE"'"
		REVERT="'"$REVERT"'"
		PRESERVE="'"$PRESERVE"'"
		drop_caches=""
		error() {
			printf "%s\n" "$@" >&2
			exit 1
		}
		FIRST_PRESERVE=1
		append_preserve() {
			local f="$1"
			local post

			case "$PRESERVE" in
			pre)
				# also skip if already there
				grep -qxF -- "$f" /target/etc/swupdate_preserve_files 2>/dev/null \
					&& return
				post=""
				;;
			post)
				# clear if present as pre
				if grep -qxF -- "$f" /target/etc/swupdate_preserve_files 2>/dev/null; then
					grep -vxF "$f" /target/etc/swupdate_preserve_files > /target/etc/swupdate_preserve_files.tmp \
					 && mv /target/etc/swupdate_preserve_files.tmp /target/etc/swupdate_preserve_files \
					 || error "Could not update /target/etc/swupdate_preserve_files"
				fi
				post="POST "
				;;
			*) return 0;;
			esac

			# post has priority over pre, so skip if already here
			# as post regardless of mode
			grep -qxF "POST $f" /target/etc/swupdate_preserve_files 2>/dev/null \
				&& return

			[ "$PRESERVE" = "pre" ] \
			# if post, clear if already pre

			if [ -n "$FIRST_PRESERVE" ]; then
				FIRST_PRESERVE=""
				echo "# persist_file $(date +%Y%m%d)" >> /target/etc/swupdate_preserve_files
			fi
			echo "$post$f" >> /target/etc/swupdate_preserve_files \
				|| error "Could not update /target/etc/swupdate_preserve_files"
			# make update show in overlay as well
			if [ -e /live/overlay_upper/etc/swupdate_preserve_files ]; then
				rm -f /live/overlay_upper/etc/swupdate_preserve_files
			fi
			# we need to drop cache regarldess of existence in upper
			# as just reading the file would keep invalid cache around
			drop_caches=1
			[ -z "$VERBOSE" ] || echo "Added \"$post$f\" to /etc/swupdate_preserve_files"
		}
		mkdir_a() {
			local dir="$1" ownermode

			ownermode=$(stat -c "%u:%g %a" "$dir") \
				|| error "Could not stat $dir -- got raced?"

			[ -d "/target/$dir" ] || mkdir $VERBOSE -- "/target/$dir" \
				|| error "mkdir $dir failed"
			chown "${ownermode% *}" "$dir" -- "/target/$dir" \
				|| error "chown $dir failed"
			chmod "${ownermode#* }" "$dir" -- "/target/$dir" \
				|| error "chmod $dir failed"
			touch -r "$dir" -- "/target/$dir" \
				|| error "touch $dir failed"
		}
		mkdir_p() {
			local dir="$1"
			[ -n "$dir" ] || return
			[ -d "/target/$dir" ] && return

			local parent=${dir%/*}
			mkdir_p "$parent"

			mkdir_a "$dir"
		}
	        is_whiteout() {
			local file="$1"
			[ -c "$file" ] && [ "$(stat -c %t,%T "$file")" = "0,0" ]
		}
		exists() {
			[ -e "$1" ] || [ -L "$1" ]
		}
		cp_one() {
			local file="$1"
			local base="${file#/live/overlay_upper}"
			local dest="/target$base"
			# whiteout - remove dest
			if is_whiteout "$file"; then
				rm $VERBOSE -rf -- "$dest" \
					|| error "rm $dest failed"
			# opaque dir -- also remove, but recreate
			elif [ -d "$file" ] && ! [ -L "$file" ] &&
			    [ "$(getfattr -n trusted.overlay.opaque --only-values "$file" 2>/dev/null)" = y ]; then
				rm $VERBOSE -rf -- "$dest" \
					|| error "rm $dest failed"
				mkdir_a "$base"
			# normal dir
			elif [ -d "$file" ] && ! [ -L "$file" ]; then
				if exists "$dest" && ! [ -d "$dest" ]; then
					rm $VERBOSE -rf -- "$dest" \
						|| error "rm $dest failed"
				fi
				mkdir_a "$base"
			# any other file
			else
				# remove for different-type copy (e.g. symlink cannot overwrite dir)
				rm $VERBOSE -rf -- "$dest" \
					|| error "rm $dest failed"
				cp -aT $VERBOSE -- "/mnt$base" "$dest" \
					|| error "cp $base failed"
			fi
		}
		mount --bind / /mnt &&
		mount --bind /live/rootfs /target &&
		mount -o remount,rw - /target &&
		old=$(cat /target/etc/.rootfs_update_timestamp 2>/dev/null) &&
		date +%s > /target/etc/.rootfs_update_timestamp &&
		if echo "$old" | cmp -s - /target/etc/.rootfs_update_timestamp; then
			echo "(differentiator for identical timestamps)" >> /target/etc/.rootfs_update_timestamp
		fi &&
		for file in "$@"; do
			if [ -n "$REMOVE" ]; then
				if [ -z "$RECURSE" ] && [ -d "/target/$file" ] && ! [ -L "/target/$file" ]; then
					if [ -z "$REVERT" ]; then
						rmdir $VERBOSE -- "/target$file" \
							|| error "rmdir $file failed"
					fi
					if [ -d "$file" ] && ! [ -L "$file" ]; then
						rmdir $VERBOSE -- "/live/overlay_upper$file" \
							|| error "rmdir $file failed"
					fi
				else
					# -f to avoid error if either is not present
					if [ -z "$REVERT" ]; then
						rm -f $RECURSE $VERBOSE -- "/target$file" \
							|| error "rm $file failed"
					fi
					rm -f $RECURSE $VERBOSE -- "/live/overlay_upper$file" \
						|| error "rm $file failed"
				fi
				# we removed from upper so drop caches to have kernel refresh vfs cache
				drop_caches=1
			elif [ -n "$RECURSE" ]; then
				append_preserve "$file"
				# skip if file not in upper
				[ -e "/live/overlay_upper$file" ] || continue
				mkdir_p "${file%/*}"
				find "/live/overlay_upper$file" -print0 |
					while read -d "" -r file; do
						cp_one "$file"
					done \
					|| exit
			else
				append_preserve "$file"
				# skip any file not in upper: nothing to do
				exists "/live/overlay_upper$file" || continue
				# ... and also check for whiteout
				if is_whiteout "/live/overlay_upper$file"; then
					rm -f $VERBOSE -- "/target$file" || error "rm $file failed"
				elif [ -d "$file" ] && ! [ -L "$file" ]; then
					mkdir_p "$file"
					if stat "$file"/* >/dev/null 2>&1; then
						echo "Warning: copy is not recursive, use -r if required"
					fi
					if [ -n "$PRESERVE" ]; then
						echo "Warning: -p/-P are only valid for single file or with -r"
					fi
				else
					mkdir_p "${file%/*}"
					cp -aT $VERBOSE -- "/mnt$file" "/target$file" \
						|| error "cp $file failed"
				fi
			fi
		done
		if [ -n "$drop_caches" ]; then
			echo 3 > /proc/sys/vm/drop_caches
		fi
		' -- "$@" || error "Could not save some modifications"
		sync
}

main() {
	local arg rest

	while [ "$#" -gt 0 ]; do
		arg="$1"
		case "$arg" in
		-[a-zA-Z][a-zA-Z]*)
			rest=${arg#-[a-zA-Z]}
			arg=${arg%"$rest"}
			shift
			set -- "$arg" "-$rest" "$@"
			continue
			;;
		-f|--force) FORCE=1;;
		-l|--list) LIST=1;;
		-r|--recurse) RECURSE=-r;;
		-d|-D|--delete) REMOVE=1;;
		-R|--revert) REVERT=1; REMOVE=1;;
		-p|--preserve) PRESERVE=pre;;
		-P|--preserve-post) PRESERVE=post;;
		-a|--apk)
			APK=-a
			shift
			break
			;;
		-c|--command)
			COMMAND=-c
			shift
			break
			;;
		-v|--verbose) VERBOSE=-v;;
		-h|--help)
			print_help
			exit
			;;
		--)
			shift
			break
			;;
		-*)
			print_help
			exit 1
			;;
		*)
			break
			;;
		esac
		shift
	done

	USER=${USER:-"$(id -un)"}
	[ "$USER" = "root" ] || error "persist_file must be run as root"

	if [ -n "$APK" ] || [ -n "$COMMAND" ]; then
		[ -z "$REMOVE" ] || error "$APK$COMMAND and -d are not compatible"
		[ -z "$LIST" ] || error "$APK$COMMAND and -l are not compatible"
		[ -z "$RECURSE" ] || error "-r does not make sense with $APK$COMMAND"
		[ -z "$PRESERVE" ] || error "$APK$COMMAND and -p/P are not compatible"
		[ -z "$REVERT" ] || error "$APK$COMMAND and -R are not compatible"
	fi

	if [ -n "$LIST" ]; then
		[ -z "$REMOVE" ] || error "-l and -d are not compatible"
		[ -z "$PRESERVE" ] || error "-l and -p/P are not compatible"
		[ -z "$REVERT" ] || error "-l and -R are not compatible"
	fi

	if [ -n "$REMOVE" ]; then
		[ -z "$PRESERVE" ] || error "-p/P not compatible with -d"
	fi

	if [ -n "$APK" ]; then
		apk "$@"
	elif [ -n "$COMMAND" ]; then
		run_command "$@"
	elif [ -n "$LIST" ]; then
		list "$@"
	else
		copy "$@"
	fi
}

main "$@"

