#!/bin/bash
# asymlink — wire a program version into the /System/Index symlink farm.
#
#   asymlink <Name> <Version>      link this version's files into the farm + make
#                                  it the Current version
#   asymlink -b <Name> <Version>   flip Current ONLY — no farm links (.aop bundles)
#   asymlink -r <Name>             remove the Current version's farm links
#   asymlink -o ...                on a filename clash, overwrite instead of keeping
#
# Modelled on GoboLinux's SymlinkProgram, with the deviations noted below.
#
# WHAT IS INDEXED (GoboLinux SymlinkProgram): bin, sbin, lib, libexec, include,
# share — plus `etc`, which is ours (issue #45) and goes to /System/Settings
# rather than the index; see SUBTREES below. `doc` is deliberately NOT indexed,
# and neither is Resources/ — that is metadata (PackageInfo, Dependencies), not
# content anyone should find on PATH.
#
# DEVIATION 1 — links point through Current. GoboLinux resolves symlinks with
# realpath() and links straight at the VERSION dir, so activating a new version
# means re-running SymlinkProgram to rewrite every link. We point at
# /Apps/<Name>/Current/<rel> instead, which makes activation and rollback a
# single Current relink — O(1) instead of O(files) — and is what lets `apkg
# rollback` be atomic. The cost is that a tool reading a link's target sees a
# Current path rather than a versioned one; GoboLinux needs FixDirReferences to
# rewrite .la/.pc/.cmake files for exactly this reason.
#
# DEVIATION 2 — .aop bundles are not indexed at all (-b). A GUI bundle is a
# self-contained directory launched by aoprun; its internal binaries and
# libraries are private to the app and must NOT land on PATH or the linker path.
# Only /Applications/<Name>.aop is created (by apkg). GoboLinux has no bundle
# concept, so it indexes GUI programs like any other.
#
# CONFLICTS follow GoboLinux: first writer wins. A clash with a link owned by a
# DIFFERENT package is reported and the existing link is KEPT unless -o is given.
# Replacing our own package's link (a version switch) is always allowed.
#
# Link targets are ALWAYS the on-device absolute path (/Apps/...), never
# $AROOT-prefixed: with AROOT set we are populating a tree that will be mounted
# at / somewhere else, so baking the staging path in would produce links that
# break the moment the disk is booted.
#
# See docs/anotheros-fs-layout.md. Portable POSIX sh, pipe-free (some on-device
# pipe combinations hang), host + on-device.
set -e
AROOT="${AROOT:-}"
APPS="$AROOT/Apps"
INDEX="$AROOT/System/Index"
SETTINGS="$AROOT/System/Settings"
USR="$AROOT/usr"
# GoboLinux SymlinkProgram indexes exactly these. `doc` is excluded on purpose,
# as is Resources/ (metadata, not content).
#
# `etc` is OURS, added for issue #45. It was not farmed at all, so a package's
# shipped default configuration sat in /Apps/<Name>/<Version>/etc/ and nothing
# ever read it — present on disk, completely inert. fontconfig, x, gtk3,
# twm, wpa_supplicant, e2fsprogs and epiphany all installed "with their defaults"
# and ran without them.
#
# It is walked and conflict-checked exactly like the others, but it lands in a
# DIFFERENT root with its leading component STRIPPED: `etc/fonts/fonts.conf`
# becomes $SETTINGS/fonts/fonts.conf, i.e. /System/Settings/fonts/fonts.conf, and
# is reached as /etc/fonts/fonts.conf because /etc -> System/Settings on every
# root shape (layout/skeleton.sh). Farming it as /System/Index/etc/... instead
# would have been just as invisible as not farming it.
#
# Settings are NOT mirrored into /usr: /usr is the index, configuration does not
# live there on any layout, and /etc already reaches System/Settings directly.
SUBTREES="bin sbin lib libexec include share etc"
# Destination for one farmable relative path. Echoes the link path; the caller
# also needs to know whether to mirror into /usr, which is "not a settings path".
link_path() {   # link_path <rel>
    case "$1" in
        etc/*) printf '%s/%s' "$SETTINGS" "${1#etc/}" ;;
        *)     printf '%s/%s' "$INDEX" "$1" ;;
    esac
}
is_settings() { case "$1" in etc/*) return 0 ;; *) return 1 ;; esac; }
# Link targets are on-device absolute paths, independent of $AROOT (see header).
DEV_APPS="/Apps"
TMP="${TMPDIR:-/tmp}/asymlink.$$"
OVERWRITE=0

# COMPAT FARM.
#
# On an INSTALLED disk, layout/skeleton.sh makes /usr a symlink to /System/Index,
# so a farm link is already reachable as /usr/bin/foo — nothing more to do.
#
# On the LIVE (initramfs/squashfs) system it is not: /bin, /lib and /usr/bin are
# real directories and there is no /System/Index at all. A program installed into
# /Apps there would be farmed to /System/Index/bin, which is not on PATH and not
# /usr/bin — i.e. installed and invisible. So every farmed path is ALSO linked
# into /usr/<sub>, putting binaries in /usr/bin and libraries in /usr/lib where
# every ported program expects them.
#
# usr_is_farm() detects the installed-disk case (/usr and the farm are the same
# directory) and skips the mirror, so we never try to link a file onto itself.
usr_is_farm() {
    [ -d "$USR" ] || return 1
    a=$(cd "$USR" 2>/dev/null && pwd -P) || return 1
    b=$(cd "$INDEX" 2>/dev/null && pwd -P) || return 1
    [ "$a" = "$b" ]
}

usage() { echo "usage: asymlink [-o] <Name> <Version> | asymlink -b <Name> <Version> | asymlink -r <Name>" >&2; exit 2; }
cleanup() { rm -f "$TMP" "$TMP".*; }
trap cleanup EXIT

# Decide whether we may (re)create the index entry <link> pointing at <target>
# for package <N>. GoboLinux's rule, from LinkOrExpandAll's Link_Or_Expand():
#
#   free name                        -> link
#   existing link owned by SAME pkg  -> replace (this is the version-upgrade path)
#   existing link owned by OTHER pkg -> CONFLICT: keep the incumbent, report it,
#                                       unless --overwrite
#
# "Owned by the same package" is decided by the first path component after
# /Apps in the existing link's target, compared case-insensitively — the same
# test GoboLinux's belongs_to_same_app() makes.
link_ok() {   # link_ok <linkpath> <target> <Name>
    lp="$1"; want="$2"; n="$3"
    [ -e "$lp" ] || [ -L "$lp" ] || return 0        # free
    old=$(readlink "$lp" 2>/dev/null) || old=""
    [ -z "$old" ] && { [ "$OVERWRITE" = 1 ] && return 0
                       echo "asymlink: conflict: $lp is a real file (not ours)" >&2; return 1; }
    [ "$old" = "$want" ] && return 0                # already correct
    owner=${old#"$DEV_APPS"/}; owner=${owner%%/*}
    lo=$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]')
    ln_=$(printf '%s' "$n"    | tr '[:upper:]' '[:lower:]')
    [ "$lo" = "$ln_" ] && return 0                  # our own package, older version
    [ "$OVERWRITE" = 1 ] && { echo "asymlink: replaced $lp (was $owner's)" >&2; return 0; }
    echo "asymlink: conflict: $lp already owned by $owner — keeping it" >&2
    return 1
}

# Write a version's farmable files (regular files + symlinks, relative to the
# version root) to $TMP — no pipes.
farm_files() {   # farm_files <versiondir> -> non-dir paths (files + symlinks) in $TMP
    : > "$TMP"
    for s in $SUBTREES; do
        [ -d "$1/$s" ] || continue
        # Plain `find` then skip dirs with a [ -d ] test: busybox `find -type`
        # relies on getdents d_type, which is DT_UNKNOWN for ext2 entries here, so
        # `-type f` / `! -type d` match nothing. `[ -d ]` uses stat and works.
        ( cd "$1" && find "$s" 2>/dev/null ) > "$TMP.all" || true
        while IFS= read -r rel; do
            [ -n "$rel" ] || continue
            [ -d "$1/$rel" ] && continue
            printf '%s\n' "$rel" >> "$TMP"
        done < "$TMP.all"
    done
    rm -f "$TMP.all"
}

do_link() {
    N="$1"; V="$2"
    vdir="$APPS/$N/$V"
    [ -d "$vdir" ] || { echo "asymlink: no such version: $vdir" >&2; exit 1; }

    # Remember what the OUTGOING version had farmed. Switching versions only ever
    # ADDED links before, so a file that existed in the old version and not in the
    # new one left a farm entry pointing through Current at a path that no longer
    # exists — a dangling link on PATH. Collect the old list first, then prune the
    # difference after the new one is linked.
    OLDV=""
    [ -L "$APPS/$N/Current" ] && OLDV=$(readlink "$APPS/$N/Current")
    : > "$TMP.old"
    if [ -n "$OLDV" ] && [ "$OLDV" != "$V" ] && [ -d "$APPS/$N/$OLDV" ]; then
        farm_files "$APPS/$N/$OLDV"; cp "$TMP" "$TMP.old"
    fi

    rm -f "$APPS/$N/Current"
    ln -s "$V" "$APPS/$N/Current"

    # -b: flip Current and stop. A .aop bundle's contents stay private to the app.
    if [ "$BUNDLE_ONLY" = 1 ]; then
        rm -f "$TMP.old"
        echo "asymlink: $N $V is Current (bundle — not indexed)"
        return 0
    fi

    COMPAT=0; usr_is_farm || COMPAT=1
    farm_files "$vdir"
    conflicts=0
    while IFS= read -r rel; do
        [ -n "$rel" ] || continue
        tgt="$DEV_APPS/$N/Current/$rel"
        lp=$(link_path "$rel")
        mkdir -p "$(dirname "$lp")"
        if link_ok "$lp" "$tgt" "$N"; then
            ln -sfn "$tgt" "$lp"
        else
            conflicts=$((conflicts+1))
        fi
        # /usr mirrors the INDEX only. A settings path is already reachable as
        # /etc/... on both the live and the installed layouts.
        if [ "$COMPAT" = 1 ] && ! is_settings "$rel"; then
            mkdir -p "$USR/$(dirname "$rel")"
            link_ok "$USR/$rel" "$tgt" "$N" && ln -sfn "$tgt" "$USR/$rel"
        fi
    done < "$TMP"
    [ "$conflicts" -gt 0 ] && \
        echo "asymlink: $conflicts conflict(s) kept the existing owner (use -o to overwrite)" >&2

    # Prune farm entries the outgoing version owned that the incoming one does
    # not provide. Only ours are removed: the readlink test makes sure we never
    # unlink a path another package now owns.
    while IFS= read -r rel; do
        [ -n "$rel" ] || continue
        grep -qxF "$rel" "$TMP" && continue
        lp=$(link_path "$rel")
        case "$(readlink "$lp" 2>/dev/null)" in
            "$DEV_APPS/$N/Current/$rel") rm -f "$lp" ;;
        esac
        if [ "$COMPAT" = 1 ] && ! is_settings "$rel"; then
            case "$(readlink "$USR/$rel" 2>/dev/null)" in
                "$DEV_APPS/$N/Current/$rel") rm -f "$USR/$rel" ;;
            esac
        fi
    done < "$TMP.old"
    rm -f "$TMP.old"
    echo "asymlink: linked $N $V into $INDEX"
}

do_unlink() {
    N="$1"
    cur="$APPS/$N/Current"
    [ -e "$cur" ] || { echo "asymlink: $N not linked" >&2; return 0; }
    V=$(readlink "$cur")
    COMPAT=0; usr_is_farm || COMPAT=1
    farm_files "$APPS/$N/$V"
    while IFS= read -r rel; do
        [ -n "$rel" ] || continue
        # Only OUR link is removed. A real file at a settings path — a user's
        # edit that won the first-writer-wins conflict — has no readlink target
        # and survives the uninstall, which is the point.
        l=$(link_path "$rel")
        case "$(readlink "$l" 2>/dev/null)" in
            "$DEV_APPS/$N/Current/$rel") rm -f "$l" ;;
        esac
        if [ "$COMPAT" = 1 ] && ! is_settings "$rel"; then
            case "$(readlink "$USR/$rel" 2>/dev/null)" in
                "$DEV_APPS/$N/Current/$rel") rm -f "$USR/$rel" ;;
            esac
        fi
    done < "$TMP"
    rm -f "$cur"
    echo "asymlink: unlinked $N"
}

BUNDLE_ONLY=0
MODE=link
while [ $# -gt 0 ]; do
    case "$1" in
        -o|--overwrite) OVERWRITE=1; shift ;;
        -b|--bundle)    BUNDLE_ONLY=1; shift ;;
        -r|--remove)    MODE=unlink; shift ;;
        --)             shift; break ;;
        -*)             usage ;;
        *)              break ;;
    esac
done

case "$MODE" in
    unlink) [ $# -eq 1 ] || usage; do_unlink "$1" ;;
    link)   [ $# -eq 2 ] || usage; do_link "$1" "$2" ;;
esac
