#!/bin/bash -eu
# SPDX-License-Identifier: MIT
# This is written in /bin/bash on purpose.

# Loosely based on code from dracut convertfs.sh.

LC_ALL=C; export LC_ALL

print_h() {
	printf >&2 "usr-m: %s\n" "$1"
}

print_cont() {
	printf >&2 "     : %s\n" "$@"
}

info() {
	if (( $# > 0 )); then
		print_h "$1"
	fi
	if (( $# > 1 )); then
		shift
		print_cont "$@"
	fi
}

fatal() {
	info "$@" "Exiting."
	exit 1
}

usage() {
	printf "usage: %s\n" "$0 [ -r ROOT ]"
	printf "options:\n"
	printf "  %-16s  %s\n" "-r ROOT" "override hierarchy root"
}

if [ -c /dev/null ] && [ -r /dev/null ]; then
	exec < /dev/null
fi

ROOT=
opt_force=
while getopts 'hfr:' opt; do case "$opt" in
h)
	usage
	exit 0
	;;
f)
	opt_force=1
	;;
r)
	ROOT="$OPTARG"
	;;
*)
	usage >&2
	exit 1
	;;
esac; done
shift $((OPTIND-1))
unset opt OPTARG OPTIND
[ "$#" -eq 0 ] || fatal "Too many arguments."

is_usrmerged() {
	local r=1
	for dir in bin sbin lib lib32 lib64 libx32; do
		[ -d "$ROOT/$dir" ] || continue
		[ -L "$ROOT/$dir" ] || return 1
		r=0
	done
	return "$r"
}

# Check if there's anything to do.
is_usrmerged && exit 0

# We require coreutils and findutils in our package, but cannot guarantee
# that they work. Perform a check and bail out.
if ! { rm --help && find --help; } > /dev/null; then
	fatal "Tools not functional."
fi

case "$(stat -f -c %T "${ROOT:-/}")" in
"overlayfs")
	fatal "Conversion does not work on a union FS." \
		"Your image will have to be rebuilt from a new FS tree, sorry."
	;;
esac

# Clean up after ourselves no matter how we die.
cleanup() {
	local d orig_d msg
	local -a bigmsg
	msg="No potentially destructive changes done, cleaning up."
	[ -z "$touched" ] || msg="Conversion failed, cleaning up."
	info "$msg"
	for dir in bin sbin lib lib32 lib64 libx32; do
		[ -d "$ROOT/usr/${dir}.usrmerge" ] || continue
		while read d; do
			orig_d="${d%.usr-$dir.usrmerge-entry~}"
			mv -T -- "$ROOT/usr/${dir}.usrmerge/$d" "$ROOT/usr/${dir}/$orig_d"
		done < <(find "$ROOT/usr/${dir}.usrmerge" -xdev -name "*.usr-$dir.usrmerge-entry~" -printf '%P\n')
		rm -rf -- "$ROOT/usr/${dir}.usrmerge"
	done
	bigmsg=(
		"!!! ATTENTION: if you see this message during dist-upgrade,"
		"!!! do NOT proceed to upgrade packages. If you do, chances"
		"!!! are high that your system might break beyond repair."
		"You will have to take manual action to finish the conversion."
		"In any case, do not panic."
	)
	[ -z "$touched" ] || info "${bigmsg[@]}"
}

is_mp_under() {
	local mp="$1" dir="$2"
	[ "/${mp#$ROOT/$dir}" != "/$mp" ] || [ "/${mp#$ROOT/usr/$dir}" != "/$mp" ]
}

# Test if link target of $1, relative to $ROOT, is under a dir to be relocated. If
# no, the link is to be readjusted so it points to the same location. Examples:
# * /sbin/modprobe -> ../bin/kmod should be ignored; /usr/bin/modprobe will
#   happily point to /usr/bin/kmod, which will be available at that location.
# * /lib/environment.d/99-environment.conf -> ../../etc/environment should be fixed,
#   since /usr/lib/environment.d/../../etc/environment is not /etc/environment.
# * /sbin/ifup (in Sisyphus) is an absolute link to /etc/net/scripts/ifup, and
#   will point there if moved to /usr/sbin/ifup. In p10 and earlier repos this
#   is a relative symlink and needs the same treatment as in the previous example.
# Returns:
# - the value 0 if there is no need to touch the file object at $1;
# - the value 1 if the symlink at $1 should be modified.
will_link_be_safe() {
	[ -L "$ROOT/$1" ] || return 0

	local dir idir r1 rr
	for dir in bin sbin lib lib32 lib64 libx32; do
		[ -d "$ROOT/$dir" ] || continue
		if [ "${1#/$dir/}" != "$1" ]; then
			# Prefix matched.
			[ ! -L "$ROOT/$dir" ] || return 0
			# No need to do anything with absolute links.
			r1="$(readlink -v -- "$ROOT/$1")"
			[ "${r1#/}" == "$r1" ] || return 0
			# We know $r1 is relative.
			# $r1 must still point under $ROOT, bail out otherwise.
			rr="$(readlink -eq -- "$ROOT")"
			r1="$(realpath-1 "$ROOT/$1")"
			[ -z "$ROOT" -o "${r1#$rr}" != "${r1}" ] || fatal \
				"E: We operate under \"$ROOT\", and the target" \
				"of \"$1\" is outside it."
			# Test if link target is under a dir to be relocated.
			# If yes, the link is safe.
			for idir in bin sbin lib lib32 lib64 libx32; do
				if [ "${r1#$rr/$idir}" != "$r1" ]; then
					# Matched. We are safe.
					return 0
				fi
			done
			# The link needs to be adjusted.
			return 1
		fi
	done
	# Do nothing.
	return 0
}

# This is non-empty if we have made changes to package-controlled hierarchy.
touched=

# The "main" function, which spans until invocation and end of file.
# This way we ensure the entire script is read before we begin.
usrm_perform() {

local cp_hl="-l"
local d dir f file o obj pfx

# We do not put this among the other checks above, since this check
# is not fatal.
if [ -r /proc/self/mountinfo ]; then
	# Detect mountpoints below /usr.
	# Try not to depend on mountpoint(1).
	while read mtid parid majmin mtrt mp other; do
		[ "/$ROOT/usr" = "/$mp" ] && cp_hl=""
		for dir in bin sbin lib lib32 lib64 libx32; do
			[ -d "$ROOT/$dir" ] || continue
			if is_mp_under "$mp" "$dir"; then
				fatal "Please unmount $mp before the conversion."
			fi
		done
	done < /proc/self/mountinfo
else
	# We need procfs for <() to work; this would be just a warning otherwise.
	fatal "Could not detect mountpoints: /proc unavailable"
fi

# For all suitable x, create a new directory in /usr and copy /x/* and /usr/x/*
# in there, using hard links if possible.
for dir in bin sbin lib lib32 lib64 libx32; do
	rm -rf -- "$ROOT/usr/${dir}.usrmerge"
	[ -L "$ROOT/$dir" ] && continue
	[ -d "$ROOT/$dir" ] || continue

	local fr fru fru_abs
	info "Making a copy of \`$ROOT/$dir'."
	# We take care to not modify anything in /$dir.
	cp -ax $cp_hl -- "$ROOT/$dir" "$ROOT/usr/${dir}.usrmerge"
	# If there are symlinks in $dir that point outside the directories to
	# be merged, re-adjust them.
	info "Detecting relative symlinks in \`$ROOT/$dir' that would break."
	while read d; do
		# This is the just created copy of d.
		f="$ROOT/usr/$dir.usrmerge/$d"

		will_link_be_safe "/$dir/$d" && continue
		fr="$(readlink -v -- "$ROOT/$dir/$d")"
		# This link is not safe, needs to be adjusted.
		rm -f -- "$f"
		# Cheat a bit: since the link itself will be one level deeper,
		# assume it should now go one more level back.
		ln -svf -- "../$fr" "$f"
	done < <(find "$ROOT/$dir" -xdev -type l -printf "%P\n")

	info "Resolving conflicts of \`$ROOT/$dir' and \`$ROOT/usr/$dir'."
	# It turns out cp cannot handle copying a dir over non-directories,
	# so move those away in advance.
	while read d; do
		# Test d's counterpart from /$dir.
		f="$ROOT/usr/$dir.usrmerge/$d"
		if test -L "$f" -o \( -e "$f" -a ! -d "$f" \); then
			info "W: $ROOT/$dir/$d will be removed due to conflict with directory $ROOT/usr/$dir/$d"
			rm -rf -- "$f.usrmerge-entry~"
			mv -- "$f" "$f.usrmerge-entry~"
		fi
	done < <(find "$ROOT/usr/$dir" -xdev -type d -printf "%P\n")
	# Also, `cp -T source dest' cannot copy analogous descendants
	# of source and dest over each other, e.g. source/$descendant over
	# dest/$descendant, even with option `-b' on the command line.
	# If /x/y and /usr/x/y are regular files with the same contents,
	# remove /x/y so /usr/x/y wins.
	while read d; do
		# Ensure $d is not a backup created in previous stages.
		[ "/${d%.usrmerge-entry~}" = "/$d" ] || continue
		f="$ROOT/usr/$dir.usrmerge/$d"
		[ ! -L "$f" ] || continue
		[ -f "$ROOT/usr/$dir/$d" ] || continue
		[ ! -L "$ROOT/usr/$dir/$d" ] || continue
		if cmp -- "$f" "$ROOT/usr/$dir/$d"; then
			# Just unlink the file; nothing can be lost here.
			rm -rf -- "$f"
		fi
	done < <(find "$ROOT/usr/$dir.usrmerge" -xdev -type f -printf "%P\n")
	# If /x/y is a symlink and resolves to /usr/x/y, remove /x/y
	# so /usr/x/y wins.
	while read d; do
		f="$ROOT/usr/$dir.usrmerge/$d"
		test -L "$f" || continue
		fr="$(readlink -mq -- "$ROOT/$dir/$d")"
		# The counterpart in /usr/$dir might not exist.
		fru="$(readlink -mq -- "$ROOT/usr/$dir/$d")"
		# Sometimes /x/y links to absolute /usr/x/y.
		fru_abs="$(readlink -mq -- "/usr/$dir/$d")"
		if [ "/$fr" = "/$fru" ]; then
			info "N: Will replace $ROOT/$dir/$d with $ROOT/usr/$dir/$d"
			rm -rf -- "$f.usrmerge-entry~"
			mv -- "$f" "$f.usrmerge-entry~"
		elif [ "/$fr" = "/$fru_abs" ]; then
			# Special case: the symlink at /x/y is absolute.
			info "N: Will replace absolute link $ROOT/$dir/$d with $ROOT/usr/$dir/$d"
			rm -rf -- "$f.usrmerge-entry~"
			mv -- "$f" "$f.usrmerge-entry~"
		fi
	done < <(find "$ROOT/usr/$dir.usrmerge" -xdev -type l -printf "%P\n")
	# If /usr/x/y is a symlink and resolves to /x/y, remove /usr/x/y
	# so /x/y wins.
	while read d; do
		f="$ROOT/usr/$dir.usrmerge/$d"
		test -e "$f" -a ! -L "$f" || continue
		# The counterpart in /$dir might not exist.
		fr="$(readlink -mq -- "$ROOT/$dir/$d")"
		fru="$(readlink -mq -- "$ROOT/usr/$dir/$d")"
		if [ "/$fru" = "/$fr" ]; then
			info "N: Will replace $ROOT/usr/$dir/$d with $ROOT/$dir/$d"
			rm -rf -- "$f.usr-$dir.usrmerge-entry~"
			mv -- "$ROOT/usr/$dir/$d" "$f.usr-$dir.usrmerge-entry~"
		fi
	done < <(find "$ROOT/usr/$dir" -xdev -type l -printf "%P\n")
	# If /x/y and /usr/x/y are both symlinks and have the same contents,
	# remove /x/y so /usr/x/y wins.
	while read d; do
		f="$ROOT/usr/$dir.usrmerge/$d"
		test -L "$f" || continue
		# Do not canonicalize; rpm cares about link contents.
		fr="$(readlink -q -- "$ROOT/$dir/$d")"
		fru="$(readlink -q -- "$ROOT/usr/$dir/$d")"
		if [ "/$fru" = "/$fr" ]; then
			info "N: Will replace $ROOT/$dir/$d with $ROOT/usr/$dir/$d"
			rm -rf -- "$f.usrmerge-entry~"
			mv -- "$f" "$f.usrmerge-entry~"
		fi
	done < <(find "$ROOT/usr/$dir" -xdev -type l -printf "%P\n")
	# If -f is not specified on the command line, bail out.
	# Otherwise, for all other similar conflicts, prefer /usr/x/y
	# over /x/y.
	while read d; do
		# Ensure $d is not a backup created in previous stages.
		[ "/${d%.usrmerge-entry~}" = "/$d" ] || continue
		f="$ROOT/usr/$dir.usrmerge/$d"
		if [ -e "$ROOT/usr/$dir/$d" ]; then
			# Tell the user something about the nature of participating files.
			local -a c_files
			readarray -t c_files < <(file -p "$ROOT/$dir/$d" "$ROOT/usr/$dir/$d")
			if [ -n "$opt_force" ]; then
				info \
					"W: $ROOT/$dir/$d conflicts with $ROOT/usr/$dir/$d and will be removed." \
					"${c_files[@]}"
				rm -rf -- "$f.usrmerge-entry~"
				mv -- "$f" "$f.usrmerge-entry~"
			else
				fatal \
					"E: $ROOT/$dir/$d conflicts with $ROOT/usr/$dir/$d; not resolving." \
					"${c_files[@]}"
			fi
		fi
	done < <(find "$ROOT/usr/$dir.usrmerge" -xdev -type f,l -printf "%P\n")

	info "Merging \`$ROOT/usr/$dir' into the copy."
	[ -d "$ROOT/usr/${dir}.usrmerge" ] \
		|| mkdir -p "$ROOT/usr/${dir}.usrmerge"
	cp -Tax -l -b --suffix=.usrmerge-entry~ -- "$ROOT/usr/$dir" "$ROOT/usr/${dir}.usrmerge"

	# The entries in /usr/x override those in /x for each relevant x.
	info "Cleaning up duplicates in the new \`$ROOT/usr/$dir'."
	# Delete all symlinks that have been backed up.
	find "$ROOT/usr/${dir}.usrmerge" -xdev -type l -name '*.usrmerge-entry~' -delete ||:
done

# Switch over the new merged dirs in /usr.
for dir in bin sbin lib lib32 lib64 libx32; do
	if [ -d "$ROOT/usr/${dir}.usrmerge" ]; then
		info "Switching to new \`$ROOT/usr/$dir'."
		touched=1
		# Make use of a C wrapper around renameat2(RENAME_EXCHANGE).
		mv-xchg -v "$ROOT/usr/${dir}.usrmerge" "$ROOT/usr/$dir"
	fi
done
if [ -z "$ROOT" ]; then
	info "Syncing \`$ROOT/usr'."
	sync /usr ||:
fi

# Replace dirs in / with links to /usr.
for dir in bin sbin lib lib32 lib64 libx32; do
	if [ ! -L "$ROOT/$dir" -a -d "$ROOT/$dir" ]; then
		info "Replacing \`$ROOT/$dir' with a symlink."
		rm --one-file-system -rf -- "$ROOT/${dir}.usrmerge" ||:
		ln -s usr/$dir "$ROOT/${dir}.usrmerge"
		touched=1
		# Make use of a C wrapper around renameat2(RENAME_EXCHANGE).
		# Before and after the call to mv-xchg the paths in /x/*
		# continue to work, so no risk to lose coreutils here.
		# XXX: If the kernel does not support renameat2 or
		# the RENAME_EXCHANGE flag, then there is a time window when
		# anything that accesses /x will find it missing.
		mv-xchg -v "$ROOT/${dir}.usrmerge" "$ROOT/$dir"
	fi
done
if [ -z "$ROOT" ]; then
	info "Syncing \`$ROOT/'."
	sync / ||:
fi

# Clean up after a successful migration.
local -a trash=( )
info "Cleaning up backup files."
for dir in bin sbin lib lib32 lib64 libx32; do
	for pfx in usr/ ''; do
		# if we get killed in the middle of "rm -rf", be sure not to
		# leave an incomplete directory.
		d="$ROOT/${pfx}${dir}.usrmerge"
		if [ -d "$d" ]; then
			rm --one-file-system -rf -- "$d~"
			mv -- "$d" "$d~"
			trash+=( "$d" )
		fi
	done
done
for d in "${trash[@]}"; do
	print_cont "$d ..."
	rm --one-file-system -rf -- "$d~"
done

# Mask the phased-out .so files so they are never picked up by ELF linkers
# and loaders.
# XXX: Is this needed at all?
info "Hiding duplicate \`lib*.so*.*'."
for dir in lib lib32 lib64 libx32; do
    [ -d "$ROOT/$dir" ] || continue
    for obj in "$ROOT"/usr/${dir}/lib*.so*.usrmerge-entry~; do
        [ -f "$obj" ] || continue
        mv -v -- "$obj" "${obj/.so/_so}"
    done
done

set +e

info "Running ldconfig."
ldconfig ${ROOT:+-r "$ROOT"} ||:

}

trap 'ret=$?; set +eux; [ $ret -eq 0 ] || cleanup; exit $ret;' EXIT
trap 'exit 1;' SIGINT

PATH="/usr/libexec/usrmerge:$PATH"
usrm_perform "$@"
