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

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

# Directory mappings
DIR_MAP="bin:usr/bin sbin:usr/sbin lib:usr/lib lib32:usr/lib32 lib64:usr/lib64"

# 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
# $1: message to print
log_error() {
    printf "ERROR: %s\n" "$1" >&2
}

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 "   --check	run with set -ex"
    echo "   --help	show this help"
}

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

# Remove copied commands
cleanup() {
    if [ -n "$getfattr" ]; then
        $busybox rm $getfattr
    fi
    if [ -n "$setfattr" ]; then
        $busybox rm $setfattr
    fi
    $busybox rm -f $busybox
}

# The script depends exclusively on the shell and busybox (plus optionally the
# attr package). But those files are binary files that will be moved as the
# script progresses! So we need to copy them in a location we know is safe, and
# use them from there!
initialize_commands() {
    local busybox_path getfattr_path setfattr_path
    if [ -x /bin/busybox ]; then
        busybox_path="/bin/busybox"
    elif [ -x /usr/bin/busybox ]; then
        busybox_path="/usr/bin/busybox"
    elif command -v "busybox" >/dev/null 2>&1; then
        busybox_path="$(command -v "busybox")"
    else
        die "busybox does not exist, but is a dependency for the script"
    fi
    busybox mkdir -p /usr/local/bin
    busybox="$(busybox mktemp -up /usr/local/bin busybox.XXXXXX)"
    busybox cp $busybox_path $busybox || die "cannot initialize busybox"
    log_debug "busybox is: $busybox"

    if [ -x /bin/getfattr ] && [ -x /bin/setfattr ]; then
        getfattr_path="/bin/getfattr"
        setfattr_path="/bin/setfattr"
    elif [ -x /usr/bin/getfattr ] && [ -x /usr/bin/setfattr ]; then
        getfattr_path="/usr/bin/getfattr"
        setfattr_path="/usr/bin/setfattr"
    elif command -v "getxattr" >/dev/null 2>&1 && command -v "setxattr" >/dev/null 2>&1; then
        getfattr_path="$(command -v "getfattr")"
        setfattr_path="$(command -v "setfattr")"
    else
        log_warning "{get,set}xattr missing or not in the same location. xattrs will not be moved"
    fi

    if [ -n "$getfattr_path" ]; then
        getfattr="$(busybox mktemp -up /usr/local/bin getfattr.XXXXXX)"
        busybox cp $getfattr_path $getfattr
        setfattr="$(busybox mktemp -up /usr/local/bin setfattr.XXXXXX)"
        busybox cp $setfattr_path $setfattr
        log_debug "getfattr is: $getfattr"
        log_debug "setfattr is: $setfattr"
    fi

    trap cleanup EXIT
}

# 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

    # Handle empty result
    if [ -z "$result" ]; then
        echo "."
    else
        echo "$result"
    fi
}

# 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' > "$target"
    else
        # If no tools are available, create an empty file
        : > "$target"
    fi
}

# 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=$(echo "$line" | cut -d'=' -f1)
            attr_value=$(echo "$line" | cut -d'=' -f2-)
            $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() {
    [ -f "$1" ] && ! [ -L "$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() {
    [ -d "$1" ] && ! [ -L "$1" ]
}

# Creates directory if it doesn't exist or verifies it is a directory
# $1: path to create or verify
# returns: 0 on success, 1 if path exists but is not a directory
ensure_directory() {
    local path="$1"

    if [ ! -e "$path" ]; then
        "$busybox" mkdir -p "$path"
        "$busybox" chmod 755 "$path"
    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)

    "$busybox" ln -sf "$target" "$tmpfile"
    copy_xattrs "$src" "$tmpfile"
    "$busybox" mv -f "$tmpfile" "$src"
}

# 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 [ ! -e "$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

    "$busybox" ln -sf "$target" "$dst"
    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
        "$busybox" cp -a "$src" "$tmp"
    fi

    "$busybox" mv -f "$tmp" "$dst"
    replace_file_with_symlink "$src" "$dst"
}

# Recursively copies a directory tree, resolving conflicts
# $1: source directory path
# $2: destination directory path
# returns: number of errors encountered during copy
copy_tree() {
    local srcdir="$1"
    local dstdir="$2"
    local errors=0

    for entry in "$srcdir"/*; do
        # Skip if entry doesn't exist (no matches)
        if [ ! -e "$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'"

            local result=0
            check_conflict "$src" "$dst" "true"
            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'"
                errors=$((errors + 1))
            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'"
                errors=$((errors + 1))
            fi
        elif is_dir "$src"; then
            # Handle directory
            log_debug "Comparing directory '$src' to '$dst'"

            if ensure_directory "$dst"; then
                # Recursively copy the directory
                copy_tree "$src" "$dst"
                local suberrors=$?
                errors=$((errors + suberrors))
            else
                log_error "Conflict for directory '$src'"
                errors=$((errors + 1))
            fi
        else
            log_error "Special file '$src': Special files are not supported"
            errors=$((errors + 1))
        fi
    done

    return $errors
}

# Merges directories according to the DIR_MAP mapping
# returns: 0 on success, 1 if errors occurred
merge_usr() {
    local success=true

    for mapping in $DIR_MAP; do
        local src_dir
        local dst_dir

        src_dir=$(echo "$mapping" | $busybox cut -d':' -f1)
        dst_dir=$(echo "$mapping" | $busybox cut -d':' -f2)

        local src="$ROOT$PREFIX$src_dir"
        local dst="$ROOT$PREFIX$dst_dir"

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

        if [ ! -e "$src" ]; then
            continue
        fi

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

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

        # Make sure the destination directory exists
        ensure_directory "$dst"

        # Copy the tree
        copy_tree "$src" "$dst"
        local errors=$?

        if [ $errors -gt 0 ]; then
            log_error "Leaving '$src' as a directory due to prior errors"
            success=false
            continue
        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
    done

    if [ "$success" = "true" ]; then
        return 0
    else
        return 1
    fi
}

# 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"
                ;;
            --no-dryrun)
                DRYRUN="false"
                ;;
            --root=*)
                ROOT="${1#*=}"
                ;;
            --root)
                shift
                ROOT="$1"
                ;;
            --prefix=*)
                PREFIX="${1#*=}"
                ;;
            --prefix)
                shift
                PREFIX="$1"
                ;;
            --verbose)
                VERBOSE="true"
                ;;
            --check)
                set -ex
                ;;
            --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
}

# Set umask for security
umask 077

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

# Initialize commands
initialize_commands

# Run the merge
merge_usr

exit $?
