#!/bin/ash
# shellcheck disable=SC2154
#
# POSIX-compliant busybox-dependent script to merge /usr in Alpine Linux

# Default values
DRYRUN="false"
ROOT="/"
PREFIX=""
VERBOSE="false"

# Prints debug message to stderr if verbose mode is enabled
# $1: message to print
log_debug() {
	if [ "$VERBOSE" = "true" ]; then
		printf "DEBUG: %s\n" "$1" >&2
	fi
}

# Prints informational message to stderr
# $1: message to print
log_info() {
	printf "INFO: %s\n" "$1" >&2
}

# Prints warning message to stderr
# $1: message to print
log_warning() {
	printf "WARNING: %s\n" "$1" >&2
}

# Prints error message to stderr and set non-zero exit code
# $1: message to print
log_error() {
	printf "ERROR: %s\n" "$1" >&2
	ret=1
}

usage() {
	echo "usage: merge-usr [OPTIONS]"
	echo "   migrate to a /usr-merged system"
	echo " Options:"
	echo "   --dryrun  just attempt the /usr-merge"
	echo "   --root    use a different root to /"
	echo "   --prefix  add prefix to root"
	echo "   --verbose extra verbose printing"
	echo "   --help    show this help"
}

# Prints error message and exits with error
# $1: message to print
die() {
	log_error "$1"
	exit 1
}

# The script depends exclusively on the shell and busybox (plus optionally the
# attr package). Make sure we know where to find them through the complete
# execution of the script
initialize_commands() {
	if ! busybox="$(command -v busybox || command -v busybox.static)" 2>/dev/null; then
		die "busybox does not exist, but is a dependency for the script"
	fi
	log_debug "busybox is: $busybox"

	if ! { getfattr="$(command -v getfattr)" 2>/dev/null && setfattr="$(command -v setfattr)"; }; then
		log_warning "{get,set}xattr missing or not in the same location. xattrs will not be moved"
	fi
}

# Calculates relative path from one absolute path to another
# $1: source path (absolute)
# $2: destination path (absolute)
# returns: relative path from source to destination
relative_path() {
	local from="$1"
	local to="$2"
	local common_part="$from"
	local result=""

	# Find the common part
	while [ "${to#"$common_part"}" = "$to" ]; do
		common_part=$("$busybox" dirname "$common_part")
		if [ "$common_part" = "/" ]; then
			break
		fi
	done

	# If they share nothing, return the absolute path
	if [ "$common_part" = "/" ]; then
		echo "$to"
		return
	fi

	# Build the relative path
	local forward_part="${from#"$common_part"}"
	if [ -z "$forward_part" ]; then
		forward_part="."
	fi

	local IFS="/"
	for component in $forward_part; do
		if [ -n "$component" ] && [ "$component" != "." ]; then
			result="$result../"
		fi
	done

	# Append the destination path
	local to_part="${to#"$common_part"/}"
	if [ "$to_part" = "$to" ]; then
		to_part="${to#"$common_part"}"
	fi

	if [ -n "$to_part" ]; then
		result="$result$to_part"
	fi

	echo "${result:-.}"
}

# Extracts extended attributes from a file and saves to target file
# $1: path of file to extract attributes from
# $2: target file to save attributes to
get_xattrs() {
	local path="$1"
	local target="$2"

	# Try using getfattr if available
	if [ -n "$getfattr" ]; then
		"$getfattr" -d -m - "$path" 2>/dev/null | sed -n 's/^# file: .*//;/^$/d;/^[^#]/p'
	fi > "$target"
}

# Sets extended attributes on a file from a source file
# $1: path of file to set attributes on
# $2: source file containing attributes
# returns: 0 on success
set_xattrs() {
	local path="$1"
	local source="$2"

	if [ ! -s "$source" ]; then
		return 0
	fi

	# Try using setfattr if available
	if [ -n "$setfattr" ]; then
		while IFS= read -r line; do
			[ -z "$line" ] && continue
			attr_name=${line%%=*}
			attr_value=${line#*=}
			"$setfattr" -n "$attr_name" -v "$attr_value" "$path" 2>/dev/null
		done < "$source"
	fi

	return 0
}

# Copies extended attributes from source file to destination file
# $1: source file path
# $2: destination file path
copy_xattrs() {
	local src="$1"
	local dst="$2"
	local tmpfile

	tmpfile=$("$busybox" mktemp -p "$("$busybox" dirname "$dst")" merge_usr.XXXXXX)
	get_xattrs "$src" "$tmpfile"
	set_xattrs "$dst" "$tmpfile"
	"$busybox" rm -f "$tmpfile"
}

# Checks if the path is a symbolic link
# $1: path to check
# returns: true (0) if path is a symlink, false (1) otherwise
is_symlink() {
	[ -L "$1" ]
}

# Checks if the path is a regular file and not a symlink
# $1: path to check
# returns: true (0) if path is a regular file, false (1) otherwise
is_file() {
	! [ -L "$1" ] && [ -f "$1" ]
}

# Checks if the path is a directory and not a symlink
# $1: path to check
# returns: true (0) if path is a directory, false (1) otherwise
is_dir() {
	! [ -L "$1" ] && [ -d "$1" ]
}

# Checks if there is anything at the given path
# $1: path to check
# returns: true (0) if there is, false (1) otherwise
is_something() {
	[ -L "$1" ] || [ -e "$1" ]
}

# Creates directory if it doesn't exist or verifies it is a directory
# $1: path to create or verify
# $2: source directory to copy the permissions from
# returns: 0 on success, 1 if path exists but is not a directory
ensure_directory() {
	local path="$1"
	local src="$2"
	if [ ! -e "$path" ]; then
		if [ "$DRYRUN" = "false" ]; then
			"$busybox" mkdir -m $("$busybox" stat -c %a "$src") "$path"
		fi
	elif [ ! -d "$path" ]; then
		log_error "Path exists but is not a directory: '$path'"
		return 1
	fi
	return 0
}

# Resolves a symbolic link to its target path
# $1: symlink path to resolve
# returns: resolved absolute path
resolve_symlink() {
	local path="$1"
	local resolved

	if is_symlink "$path"; then
		resolved=$("$busybox" readlink "$path")
		if [ "${resolved#/}" != "$resolved" ]; then
			# Absolute path
			path="$ROOT${resolved#/}"
		else
			# Relative path
			path="$("$busybox" dirname "$path")/$resolved"
		fi
	fi

	"$busybox" realpath "$path"
}

# Replaces a file with a symbolic link to another file
# $1: source file to replace with symlink
# $2: destination file to link to
replace_file_with_symlink() {
	local src="$1"
	local dst="$2"
	local srcdir
	local target
	local tmpfile

	srcdir=$("$busybox" dirname "$src")
	target=$(relative_path "$srcdir" "$dst")
	tmpfile=$("$busybox" mktemp -p "$srcdir" merge_usr.XXXXXX)

	if ! "$busybox" ln -sf "$target" "$tmpfile"; then
		log_error "failed creating temp link '$tmpfile' to '$target'"
		return 1
	fi
	copy_xattrs "$src" "$tmpfile"
	if ! "$busybox" mv -f "$tmpfile" "$src"; then
		log_error "failed moving temp link '$tmpfile' to source '$src'"
		return 1
	fi
}

# Checks for conflicts when merging directories
# $1: source path
# $2: destination path
# $3: boolean indicating if source is a symlink
# returns: 0 if no conflict, 1 if conflict detected, 2 if symlink should be skipped
check_conflict() {
	local src="$1"
	local dst="$2"
	local src_is_symlink="$3"
	local src_real
	local dst_real

	if ! is_something "$dst"; then
		return 0  # No conflict
	fi

	if [ "$src_is_symlink" = "true" ]; then
		src_real=$(resolve_symlink "$src")
	else
		src_real="$src"
	fi

	if is_symlink "$dst"; then
		dst_real=$(resolve_symlink "$dst")

		# If they point to the same location, it's not a conflict
		if [ "$src_real" = "$dst_real" ]; then
			if [ "$src_is_symlink" = "true" ]; then
				return 2  # Skip symlink
			fi
			return 0
		fi

		# Check if destination is a busybox symlink
		if [ "$("$busybox" readlink "$dst")" = "/bin/busybox" ]; then
			return 0  # Overwrite busybox symlink
		fi

		# Check for special symlinks like /lib64/lp64d -> .
		if [ "$src_is_symlink" = "true" ] && is_symlink "$dst"; then
			local srcdir dstdir srcrel dstrel
			srcdir=$("$busybox" dirname "$src")
			dstdir=$("$busybox" dirname "$dst")

			if [ "${src_real#"$srcdir"}" != "$src_real" ] && [ "${dst_real#"$dstdir"}" != "$dst_real" ]; then
				srcrel=$(relative_path "$srcdir" "$src_real")
				dstrel=$(relative_path "$dstdir" "$dst_real")

				if [ "$srcrel" = "$dstrel" ]; then
					return 2  # Skip symlink
				fi
			fi
		fi
	elif [ "$src_real" = "$dst" ]; then
		return 2  # src is a symlink pointing to dst, skip it
	fi

	# Don't replace $dst with busybox symlinks, regardless of what $dst is
	if [ "$src_is_symlink" = "true" ] && [ "$("$busybox" readlink "$src")" = "/bin/busybox" ]; then
		log_warning "Skipping busybox symlink since dest exists: $dst. This is likely a packaging bug"
		return 2  # Skip symlink
	fi

	# If we got here, there's a conflict
	log_error "Conflict detected: '$dst' already exists"
	return 1
}

# Copies a symbolic link from source to destination
# $1: source symlink path
# $2: destination path
copy_symlink() {
	local src="$1"
	local dst="$2"
	local srcdir
	local dstdir
	local target

	log_debug "Copying symlink '$src' to '$dst'"

	srcdir=$("$busybox" dirname "$src")
	dstdir=$("$busybox" dirname "$dst")
	target=$("$busybox" readlink "$src")

	if [ "${target#/}" = "$target" ]; then
		# Relative symlink
		local t
		t="$srcdir/$target"
		t=$(resolve_symlink "$t")

		if [ "${t#"$srcdir"}" != "$t" ]; then
			target=$(relative_path "$srcdir" "$t")
		else
			target=$(relative_path "$dstdir" "$t")
		fi
	fi

	if ! "$busybox" ln -sf "$target" "$dst"; then
		log_error "failed creating link '$dst' to '$target'"
		return 1
	fi
	copy_xattrs "$src" "$dst"
}

# Creates a hard link or copies a file if hard linking fails
# $1: source file path
# $2: destination file path
link_or_copy_file() {
	local src="$1"
	local dst="$2"
	local tmp

	log_debug "Copying file '$src' to '$dst'"

	tmp=$("$busybox" mktemp -up "$("$busybox" dirname "$dst")" merge_usr.XXXXXX)

	if ! "$busybox" ln "$src" "$tmp" 2>/dev/null; then
		if ! "$busybox" cp -a "$src" "$tmp"; then
			log_error "failed copying source '$src' to temp '$tmp'"
			return 1
		fi
	fi

	if ! "$busybox" mv -f "$tmp" "$dst"; then
		log_error "failed moving temp '$tmp' to dest '$dst'"
		return 1
	fi
	replace_file_with_symlink "$src" "$dst"
}

# Recursively copies a directory tree, resolving conflicts
# $1: source directory path
# $2: destination directory path
copy_tree() {
	local srcdir="$1"
	local dstdir="$2"

	for entry in "$srcdir"/*; do
		# Skip if entry doesn't exist (no matches)
		if ! is_something "$entry"; then
			continue
		fi

		local name
		name=$("$busybox" basename "$entry")

		local src="$srcdir/$name"
		local dst="$dstdir/$name"

		if is_symlink "$src"; then
			# Handle symlink
			log_debug "Comparing symlink '$src' to '$dst'"

			check_conflict "$src" "$dst" "true"
			local result=$?

			if [ $result -eq 0 ]; then
				if [ "$DRYRUN" = "false" ]; then
					copy_symlink "$src" "$dst"
				fi
			elif [ $result -eq 2 ]; then
				log_debug "Skipping symlink '$src'; '$dst' already exists"
			else
				log_error "Conflict for symlink '$src'"
			fi
		elif is_file "$src"; then
			# Handle file
			log_debug "Comparing file '$src' to '$dst'"

			if check_conflict "$src" "$dst" "false"; then
				if [ "$DRYRUN" = "false" ]; then
					link_or_copy_file "$src" "$dst"
				fi
			else
				log_error "Conflict for file '$src'"
			fi
		elif is_dir "$src"; then
			# Handle directory
			log_debug "Comparing directory '$src' to '$dst'"

			if ensure_directory "$dst" "$src"; then
				# Recursively copy the directory
				copy_tree "$src" "$dst"
			else
				log_error "Conflict for directory '$src'"
			fi
		else
			log_error "Special file '$src': Special files are not supported"
		fi

		if [ $ret -gt 0 ] && [ "$DRYRUN" = "false" ]; then
			break
		fi
	done

	return $ret
}

# Merges 2 directories according
# $1: source directory path
# $2: destination directory path
# returns: 0 on success, 1 if errors occurred
merge_path() {
	local src="$ROOT$PREFIX$1"
	local dst="$ROOT$PREFIX$2"

	if is_symlink "$src"; then
		log_info "Already a symlink: '$src'"
		return
	fi

	if ! is_something "$src" ]; then
		return
	fi

	if ! is_dir "$src"; then
		log_warning "Not a directory: '$src'"
		return
	fi

	log_info "Migrating files from '$src' to '$dst'"

	while IFS=' ' read -r from mntpoint other; do
		case "$mntpoint" in
		"$src"|"$src"/*) die "Mountpoint '$mntpoint' is in '$src', which will be removed. Please unmount or move it before attempting the merge" ;;
		esac
	done < /proc/mounts

	# Make sure the destination directory exists
	if ensure_directory "$dst" "$src"; then
		# Copy the tree
		copy_tree "$src" "$dst"
	fi

	if [ $ret -gt 0 ]; then
		log_error "Leaving '$src' as a directory due to prior errors"
		return
	fi

	if [ "$DRYRUN" = "true" ]; then
		log_info "No problems found for '$src'"
	else
		# This is a C helper shipped with this script
		merge_usr_replace_dir "$src" "$dst"
	fi
}

merge_usr() {
	merge_path bin usr/bin
	merge_path sbin usr/sbin
	merge_path lib usr/lib
	merge_path lib32 usr/lib32
	merge_path lib64 usr/lib64
}

# Parses command line arguments and sets global variables
# returns: 0 on success, 1 if invalid arguments
parse_args() {
	while [ $# -gt 0 ]; do
		case "$1" in
			--dryrun)
				DRYRUN="true"
				;;
			--root)
				shift
				ROOT="$1"
				;;
			--prefix)
				shift
				PREFIX="$1"
				;;
			--verbose)
				VERBOSE="true"
				;;
			--help)
				usage
				exit 0
				;;
			*)
				usage
				die "invalid options"
				;;
		esac
		shift
	done

	# Normalize paths
	if [ -n "$ROOT" ]; then
		ROOT="${ROOT%/}/"
	fi

	if [ -n "$PREFIX" ]; then
		PREFIX="${PREFIX#/}"
	fi

	return 0
}

# Parse command line arguments
if ! parse_args "$@"; then
	exit 1
fi

ret=0

# Initialize commands
initialize_commands

# Run the merge
merge_usr

exit $ret
