Splux Linux

source-based Linux using SPS

SPS · log · files · refs · tags · releases · people · languages · GitHub · file

bin/pkin


Shell

36912 bytes at 2909919 · GitHub

#!/bin/sh

# Install one already-built SPS package. Repository lookup and dependency
# policy belong to sget.

sps_prog=pkin
sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
if [ -z "${SPS_LIBDIR+x}" ]; then
    if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
        SPS_LIBDIR=$sps_bin_dir/../lib
    else
        SPS_LIBDIR=$sps_bin_dir/../lib/sps
    fi
fi
if ! . "$SPS_LIBDIR/common.sh"; then
    printf '%s\n' "pkin: cannot load $SPS_LIBDIR/common.sh" >&2
    exit 1
fi
sps_load_config || exit $?

sps_usage()
{
    printf '%s\n' 'usage: pkin [-v|--verbose] [-q|--quiet] [--explicit|--dependency] PACKAGE'
}

sps_say()
{
    printf '%s: %s\n' "$sps_prog" "$*" >&2
}

sps_verbose_say()
{
    [ "${sps_verbose:-1}" -eq 1 ] || return 0
    sps_say "$*"
}

sps_fail()
{
    sps_code=$1
    shift
    printf '%s: %s\n' "$sps_prog" "$*" >&2
    exit "$sps_code"
}

sps_exists()
{
    [ -f "$1" ] || [ -d "$1" ] || [ -L "$1" ]
}

sps_target_path()
{
    case $SPS_ROOT in
        /) sps_target=/$1 ;;
        */) sps_target=$SPS_ROOT$1 ;;
        *) sps_target=$SPS_ROOT/$1 ;;
    esac
}

# Do not walk through a symlink or non-directory while resolving a payload path.
# The same rule applies to alternate roots.
sps_safe_parents()
{
    sps_parent_rel=${1%/*}
    [ "$sps_parent_rel" != "$1" ] || return 0
    sps_remainder=$sps_parent_rel
    sps_prefix=
    while [ -n "$sps_remainder" ]; do
        case $sps_remainder in
            */*) sps_component=${sps_remainder%%/*}; sps_remainder=${sps_remainder#*/} ;;
            *) sps_component=$sps_remainder; sps_remainder= ;;
        esac
        if [ -n "$sps_prefix" ]; then
            sps_prefix=$sps_prefix/$sps_component
        else
            sps_prefix=$sps_component
        fi
        sps_target_path "$sps_prefix"
        if [ -L "$sps_target" ]; then
            if ! sps_is_shared_dir_link "$sps_prefix"; then
                printf '%s: unsafe symlink in target path: /%s\n' "$sps_prog" "$sps_prefix" >&2
                return 1
            fi
        elif sps_exists "$sps_target" && [ ! -d "$sps_target" ]; then
            printf '%s: non-directory in target path: /%s\n' "$sps_prog" "$sps_prefix" >&2
            return 1
        fi
    done
    return 0
}

# Check the staging tree too. Packages may contain symlinks, but another payload
# entry cannot live underneath one.
sps_safe_stage_parents()
{
    sps_stage_parent=${1%/*}
    [ "$sps_stage_parent" != "$1" ] || return 0
    sps_stage_rest=$sps_stage_parent
    sps_stage_prefix=
    while [ -n "$sps_stage_rest" ]; do
        case $sps_stage_rest in
            */*) sps_stage_component=${sps_stage_rest%%/*}; sps_stage_rest=${sps_stage_rest#*/} ;;
            *) sps_stage_component=$sps_stage_rest; sps_stage_rest= ;;
        esac
        if [ -n "$sps_stage_prefix" ]; then
            sps_stage_prefix=$sps_stage_prefix/$sps_stage_component
        else
            sps_stage_prefix=$sps_stage_component
        fi
        sps_stage_ancestor=$sps_stage/$sps_stage_prefix
        if [ -L "$sps_stage_ancestor" ] || [ ! -d "$sps_stage_ancestor" ]; then
            printf '%s: unsafe extracted parent for /%s\n' "$sps_prog" "$1" >&2
            return 1
        fi
    done
    return 0
}

sps_work=
sps_record_tmp=
sps_backup_dir=
sps_owners_base=
sps_owners_new=
sps_world_base=
sps_world_new=
sps_lock=
sps_lock_owned=0
sps_commit_complete=0
sps_record_backed_up=0
sps_database_commit_started=0

sps_cleanup()
{
    sps_status=$?
    trap - 0 1 2 3 15
    if [ "$sps_commit_complete" -eq 0 ] && [ "$sps_database_commit_started" -eq 1 ] &&
       [ -n "$sps_backup_dir" ] && [ -d "$sps_backup_dir" ]; then
        if [ -n "${sps_old_record-}" ]; then
            rm -rf "$sps_old_record"
            if [ "$sps_record_backed_up" -eq 1 ]; then
                mv "$sps_backup_dir/record" "$sps_old_record" 2>/dev/null || :
            fi
        fi
        if [ -f "$sps_backup_dir/owners" ]; then
            cp "$sps_backup_dir/owners" "$SPS_DB/.owners-restore.$$" 2>/dev/null &&
                mv -f "$SPS_DB/.owners-restore.$$" "$SPS_DB/owners" 2>/dev/null || :
        fi
        if [ -f "$sps_backup_dir/world" ]; then
            cp "$sps_backup_dir/world" "$SPS_DB/.world-restore.$$" 2>/dev/null &&
                mv -f "$SPS_DB/.world-restore.$$" "$SPS_DB/world" 2>/dev/null || :
        fi
        sps_record_backed_up=0
        sps_database_commit_started=0
    fi
    if [ -n "$sps_record_tmp" ] && [ -d "$sps_record_tmp" ]; then
        rm -rf "$sps_record_tmp"
    fi
    for sps_temp_file in "$sps_owners_base" "$sps_owners_new" \
        "$sps_world_base" "$sps_world_new"; do
        [ -n "$sps_temp_file" ] && rm -f "$sps_temp_file"
    done
    if [ -n "$sps_backup_dir" ] && [ -d "$sps_backup_dir" ]; then
        rm -rf "$sps_backup_dir"
    fi
    if [ -n "$sps_work" ] && [ -d "$sps_work" ]; then
        rm -rf "$sps_work"
    fi
    sps_lock_release
    exit "$sps_status"
}
trap sps_cleanup 0
trap 'exit 130' 1 2 3 15

sps_reason=${SPS_INSTALL_REASON:-explicit}
sps_verbose=1
case ${SPS_QUIET-} in 1|yes|true) sps_verbose=0 ;; esac
while [ "$#" -gt 0 ]; do
    case $1 in
        -v|--verbose) sps_verbose=1; shift ;;
        -q|--quiet) sps_verbose=0; shift ;;
        --explicit) sps_reason=explicit; shift ;;
        --dependency) sps_reason=dependency; shift ;;
        -h|--help) sps_usage; exit 0 ;;
        --) shift; break ;;
        -*) sps_fail 2 "unknown option '$1'" ;;
        *) break ;;
    esac
done
case $sps_reason in
    explicit|dependency) ;;
    *) sps_fail 2 "invalid install reason '$sps_reason'" ;;
esac
[ "$#" -eq 1 ] || { sps_usage >&2; exit 2; }
sps_archive=$1
[ -f "$sps_archive" ] && [ -r "$sps_archive" ] ||
    sps_fail 3 "cannot read package archive '$sps_archive'"

case $sps_archive in
    /*) ;;
    *)
        sps_archive_dir=$(CDPATH= cd -P "$(dirname "$sps_archive")" 2>/dev/null && pwd) ||
            sps_fail 3 "cannot resolve package archive '$sps_archive'"
        sps_archive=$sps_archive_dir/$(basename "$sps_archive")
        ;;
esac

sps_archive_list()
{
    case $sps_archive in
        *.tar.gz|*.tgz)
            command -v gzip >/dev/null 2>&1 ||
                sps_fail 4 "gzip is required to read '$sps_archive'"
            gzip -dc "$sps_archive" | tar -tf -
            ;;
        *.tar.xz|*.txz)
            command -v xz >/dev/null 2>&1 ||
                sps_fail 4 "xz is required to read '$sps_archive'"
            xz -dc "$sps_archive" | tar -tf -
            ;;
        *.tar.zst|*.tzst)
            command -v zstd >/dev/null 2>&1 ||
                sps_fail 4 "zstd is required to read '$sps_archive'"
            zstd -q -dc "$sps_archive" | tar -tf -
            ;;
        *)
            tar -tf "$sps_archive"
            ;;
    esac
}

sps_archive_extract()
{
    case $sps_archive in
        *.tar.gz|*.tgz) gzip -dc "$sps_archive" | tar -xf - -C "$sps_stage" ;;
        *.tar.xz|*.txz) xz -dc "$sps_archive" | tar -xf - -C "$sps_stage" ;;
        *.tar.zst|*.tzst) zstd -q -dc "$sps_archive" | tar -xf - -C "$sps_stage" ;;
        *) tar -xf "$sps_archive" -C "$sps_stage" ;;
    esac
}

mkdir -p "$SPS_BUILD" || sps_fail 8 "cannot create build directory '$SPS_BUILD'"
sps_work=$(sps_mktemp_dir "$SPS_BUILD" install) ||
    sps_fail 8 "cannot create a secure installation workspace"
sps_stage=$sps_work/stage
mkdir "$sps_stage" || sps_fail 8 "cannot create package staging directory"

if ! sps_archive_list >"$sps_work/members.raw" 2>"$sps_work/tar.error"; then
    sed 's/^/pkin: tar: /' "$sps_work/tar.error" >&2
    sps_fail 4 "invalid package archive '$sps_archive'"
fi
if ! awk -v sps_action=normalize-members -v sps_program=pkin \
    -f "$SPS_LIBDIR/package.awk" "$sps_work/members.raw" >"$sps_work/members"; then
    sps_fail 4 "unsafe package archive '$sps_archive'"
fi
for sps_control in .SPS/meta .SPS/files .SPS/hashes; do
    if ! awk -v wanted="$sps_control" '
        { value=$0; sub(/\/$/, "", value); if (value == wanted) found=1 }
        END { exit !found }
    ' "$sps_work/members"; then
        sps_fail 4 "package archive is missing $sps_control"
    fi
done

# Extract only after member names pass validation, and always into a fresh tree.
sps_say "unpacking ${sps_archive##*/}"
if ! sps_archive_extract 2>"$sps_work/tar.error"; then
    sed 's/^/pkin: tar: /' "$sps_work/tar.error" >&2
    sps_fail 4 "cannot extract package archive '$sps_archive'"
fi
for sps_control in meta files hashes; do
    [ -f "$sps_stage/.SPS/$sps_control" ] &&
        [ ! -L "$sps_stage/.SPS/$sps_control" ] ||
        sps_fail 4 "package control file .SPS/$sps_control is not a regular file"
done
if [ -e "$sps_stage/.SPS/hooks" ] || [ -L "$sps_stage/.SPS/hooks" ]; then
    [ -d "$sps_stage/.SPS/hooks" ] && [ ! -L "$sps_stage/.SPS/hooks" ] ||
        sps_fail 4 "package hook control is not a real directory"
    for sps_hook in pre-install post-install pre-remove post-remove; do
        [ ! -e "$sps_stage/.SPS/hooks/$sps_hook" ] &&
            [ ! -L "$sps_stage/.SPS/hooks/$sps_hook" ] && continue
        [ -f "$sps_stage/.SPS/hooks/$sps_hook" ] &&
            [ ! -L "$sps_stage/.SPS/hooks/$sps_hook" ] ||
            sps_fail 4 "package hook '$sps_hook' is not a regular file"
    done
fi

if ! awk -v sps_action=validate-meta -v sps_program=pkin \
    -f "$SPS_LIBDIR/package.awk" "$sps_stage/.SPS/meta"; then
    sps_fail 4 "invalid package metadata"
fi
if ! awk -v sps_action=validate-paths -v sps_program=pkin \
    -f "$SPS_LIBDIR/package.awk" "$sps_stage/.SPS/files"; then
    sps_fail 4 "invalid package manifest"
fi
if ! awk -v sps_action=validate-hashes -v sps_program=pkin \
    -f "$SPS_LIBDIR/package.awk" "$sps_stage/.SPS/hashes"; then
    sps_fail 4 "invalid package hashes"
fi

sps_id=$(awk -v sps_action=meta-id -v sps_program=pkin \
    -f "$SPS_LIBDIR/package.awk" "$sps_stage/.SPS/meta") ||
    sps_fail 4 "cannot read package identity"
sps_tab=$(printf '\tX')
sps_tab=${sps_tab%X}
sps_saved_ifs=$IFS
IFS=$sps_tab
set -- $sps_id
IFS=$sps_saved_ifs
[ "$#" -eq 4 ] || sps_fail 4 "malformed package identity"
sps_name=$1
sps_version=$2
sps_release=$3
sps_arch=$4
case $sps_arch in
    any|noarch|"$SPS_ARCH") ;;
    *) sps_fail 4 "package architecture '$sps_arch' does not match target '$SPS_ARCH'" ;;
esac

# The manifest is the package file list. Reject extra payload and missing
# manifest entries before installation.
awk '
    {
        value=$0
        sub(/\/$/, "", value)
        if (value == ".SPS" || value ~ /^\.SPS\//) next
        print value
    }
' "$sps_work/members" | LC_ALL=C sort -u >"$sps_work/archive.payload"
sed 's:/$::' "$sps_stage/.SPS/files" | LC_ALL=C sort -u >"$sps_work/manifest.payload"
if ! cmp -s "$sps_work/archive.payload" "$sps_work/manifest.payload"; then
    sps_fail 4 "archive payload does not match .SPS/files"
fi

: >"$sps_work/regulars"
: >"$sps_work/owned"
while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
    sps_path=${sps_entry%/}
    sps_source=$sps_stage/$sps_path
    sps_safe_stage_parents "$sps_path" ||
        sps_fail 4 "unsafe extracted payload path /$sps_path"
    case $sps_entry in
        */)
            [ -d "$sps_source" ] && [ ! -L "$sps_source" ] ||
                sps_fail 4 "manifest directory has the wrong type: /$sps_path"
            ;;
        *)
            if [ -f "$sps_source" ] && [ ! -L "$sps_source" ]; then
                printf '%s\n' "$sps_path" >>"$sps_work/regulars"
            elif [ -L "$sps_source" ]; then
                :
            else
                sps_fail 4 "unsupported or missing payload entry: /$sps_path"
            fi
            printf '%s\n' "$sps_path" >>"$sps_work/owned"
            ;;
    esac
done <"$sps_stage/.SPS/files"
LC_ALL=C sort -u "$sps_work/regulars" -o "$sps_work/regulars"
LC_ALL=C sort -u "$sps_work/owned" -o "$sps_work/owned"
awk -v sps_action=hash-paths -v sps_program=pkin \
    -f "$SPS_LIBDIR/package.awk" "$sps_stage/.SPS/hashes" |
    LC_ALL=C sort -u >"$sps_work/hash.paths" ||
    sps_fail 4 "invalid package hashes"
if ! cmp -s "$sps_work/regulars" "$sps_work/hash.paths"; then
    sps_fail 4 "hash records do not match the regular-file payload"
fi

while IFS="$sps_tab" read -r sps_algorithm sps_expected sps_path; do
    [ -n "$sps_algorithm" ] || continue
    sps_actual=$(sps_sha256 "$sps_stage/$sps_path") ||
        sps_fail 6 "cannot hash payload file /$sps_path"
    set -- $sps_actual
    sps_actual=$1
    [ "$sps_actual" = "$sps_expected" ] ||
        sps_fail 6 "checksum mismatch for /$sps_path"
done <"$sps_stage/.SPS/hashes"

mkdir -p "$SPS_DB" || sps_fail 8 "cannot create package database '$SPS_DB'"
if sps_exists "$SPS_DB/installed"; then
    [ -d "$SPS_DB/installed" ] && [ ! -L "$SPS_DB/installed" ] ||
        sps_fail 11 "installed database is not a real directory"
else
    mkdir "$SPS_DB/installed" || sps_fail 8 "cannot create installed database"
fi
sps_installed=$SPS_DB/installed
for sps_db_file in owners world history; do
    if sps_exists "$SPS_DB/$sps_db_file"; then
        [ -f "$SPS_DB/$sps_db_file" ] && [ ! -L "$SPS_DB/$sps_db_file" ] ||
            sps_fail 11 "database control '$sps_db_file' is not a regular file"
    else
        : >"$SPS_DB/$sps_db_file" || sps_fail 8 "cannot create database file '$SPS_DB/$sps_db_file'"
    fi
done
if ! sps_lock_acquire "$SPS_DB"; then
    sps_fail 11 "package database is locked: '$SPS_DB/.lock'"
fi

if ! awk -v sps_action=owners-validate -v sps_program=pkin \
    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/owners"; then
    sps_fail 11 "installed ownership database is malformed"
fi
if ! awk -v sps_action=world-validate -v sps_program=pkin \
    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world"; then
    sps_fail 11 "installed world database is malformed"
fi

# Validate the old installed record before using it for upgrade cleanup. A bad
# old manifest must never turn into a removal path.
sps_old_record=$SPS_DB/installed/$sps_name
sps_old_version=
sps_old_release=
: >"$sps_work/old-owned"
if sps_exists "$sps_old_record" &&
   { [ ! -d "$sps_old_record" ] || [ -L "$sps_old_record" ]; }; then
    sps_fail 11 "installed record for '$sps_name' is not a real directory"
fi
if [ -d "$sps_old_record" ]; then
    for sps_old_control in meta files hashes; do
        [ -f "$sps_old_record/$sps_old_control" ] &&
            [ ! -L "$sps_old_record/$sps_old_control" ] ||
            sps_fail 11 "installed record for '$sps_name' is missing $sps_old_control"
    done
    awk -v sps_action=validate-meta -v sps_program=pkin \
        -f "$SPS_LIBDIR/package.awk" "$sps_old_record/meta" ||
        sps_fail 11 "installed metadata for '$sps_name' is malformed"
    awk -v sps_action=validate-paths -v sps_program=pkin \
        -f "$SPS_LIBDIR/package.awk" "$sps_old_record/files" ||
        sps_fail 11 "installed manifest for '$sps_name' is malformed"
    awk -v sps_action=validate-hashes -v sps_program=pkin \
        -f "$SPS_LIBDIR/package.awk" "$sps_old_record/hashes" ||
        sps_fail 11 "installed hashes for '$sps_name' are malformed"
    sps_old_name=$(awk -v sps_action=meta-get -v sps_key_name=name \
        -v sps_program=pkin -f "$SPS_LIBDIR/package.awk" "$sps_old_record/meta") ||
        sps_fail 11 "cannot read installed package identity"
    [ "$sps_old_name" = "$sps_name" ] ||
        sps_fail 11 "installed record name '$sps_old_name' does not match '$sps_name'"
    sps_old_version=$(awk -v sps_action=meta-get -v sps_key_name=version \
        -v sps_program=pkin -f "$SPS_LIBDIR/package.awk" "$sps_old_record/meta") ||
        sps_fail 11 "installed metadata for '$sps_name' is malformed"
    sps_old_release=$(awk -v sps_action=meta-get -v sps_key_name=release \
        -v sps_program=pkin -f "$SPS_LIBDIR/package.awk" "$sps_old_record/meta") ||
        sps_fail 11 "installed metadata for '$sps_name' is malformed"
    while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
        case $sps_entry in */) ;; *) printf '%s\n' "$sps_entry" >>"$sps_work/old-owned" ;; esac
    done <"$sps_old_record/files"
    LC_ALL=C sort -u "$sps_work/old-owned" -o "$sps_work/old-owned"
fi

sps_run_hook()
{
    sps_hook_name=$1
    sps_hook_file=$sps_stage/.SPS/hooks/$sps_hook_name
    [ -f "$sps_hook_file" ] || return 0
    printf '%s: running %s hook for %s\n' "$sps_prog" "$sps_hook_name" "$sps_name" >&2
    SPS_ACTION=$sps_install_action
    SPS_PACKAGE=$sps_name
    SPS_VERSION=$sps_version
    SPS_RELEASE=$sps_release
    SPS_PACKAGE_ARCH=$sps_arch
    export SPS_ACTION SPS_PACKAGE SPS_VERSION SPS_RELEASE SPS_PACKAGE_ARCH
    sh -eu "$sps_hook_file"
}

# True when a protected regular file was changed since it was installed.
sps_old_file_modified()
{
    sps_preserve_path=$1
    sps_preserve_target=$2
    [ -d "$sps_old_record" ] || return 1
    sps_is_preserved_path "$sps_preserve_path" || return 1
    [ -f "$sps_preserve_target" ] && [ ! -L "$sps_preserve_target" ] || return 1
    sps_preserve_expected=$(awk -F "$sps_tab" -v path="$sps_preserve_path"         '$3 == path && $1 == "sha256" { print $2; exit }' "$sps_old_record/hashes")
    [ -n "$sps_preserve_expected" ] || return 1
    sps_preserve_actual=$(sps_sha256 "$sps_preserve_target") || return 0
    [ "$sps_preserve_actual" != "$sps_preserve_expected" ]
}

if [ -d "$sps_old_record" ]; then
    sps_install_action=upgrade
else
    sps_install_action=install
fi
sps_run_hook pre-install ||
    sps_fail 10 "$sps_install_action pre-install hook failed for '$sps_name'"

: >"$sps_work/self-owned"
: >"$sps_work/conflicts"
awk -F "$sps_tab" -v package="$sps_name" \
    -v paths="$sps_work/owned" -v self="$sps_work/self-owned" \
    -v conflicts="$sps_work/conflicts" '
    BEGIN {
        while ((getline path < paths) > 0) wanted[path] = 1
        close(paths)
    }
    ($1 in wanted) {
        if ($2 == package) print $1 > self
        else print $1 "\t" $2 > conflicts
    }
' "$SPS_DB/owners"
if [ -s "$sps_work/conflicts" ]; then
    while IFS="$sps_tab" read -r sps_path sps_owner; do
        printf '%s: /%s is owned by %s\n' "$sps_prog" "$sps_path" "$sps_owner" >&2
    done <"$sps_work/conflicts"
    sps_fail 7 "cannot install $sps_name-$sps_version-$sps_release"
fi
LC_ALL=C sort -u "$sps_work/self-owned" -o "$sps_work/self-owned"
comm -23 "$sps_work/owned" "$sps_work/self-owned" >"$sps_work/unowned"

# Live images and other bootstraps often already contain usr-merge
# symlinks and identical static files. Claiming the same bytes is not
# a hijack; a different target or digest still is.
sps_payload_matches_existing()
{
    sps_match_path=$1
    sps_match_target=$2
    sps_match_source=$sps_stage/$sps_match_path
    if [ -L "$sps_match_source" ] && [ -L "$sps_match_target" ]; then
        [ "$(readlink "$sps_match_source")" = "$(readlink "$sps_match_target")" ]
        return $?
    fi
    if [ -f "$sps_match_source" ] && [ ! -L "$sps_match_source" ] &&
       [ -f "$sps_match_target" ] && [ ! -L "$sps_match_target" ]
    then
        sps_match_new=$(sps_sha256 "$sps_match_source") || return 1
        sps_match_old=$(sps_sha256 "$sps_match_target") || return 1
        [ "$sps_match_new" = "$sps_match_old" ]
        return $?
    fi
    return 1
}

# Live images seed host copies and busybox applets that later packages own.
# Replace an unowned leaf there; keep ordinary systems strict.
sps_may_replace_unowned()
{
    sps_repl_path=$1
    sps_repl_target=$2
    sps_repl_source=$sps_stage/$sps_repl_path
    sps_is_live_bootstrap || return 1
    sps_is_preserved_path "$sps_repl_path" && return 1
    sps_is_live_critical_path "$sps_repl_path" && return 1
    # Never claim the four usr-merge names this way. Identical links are
    # adopted earlier; a different target remains a conflict.
    case ${sps_repl_path#/} in
        bin|sbin|lib|lib64) return 1 ;;
    esac
    if [ -d "$sps_repl_target" ] && [ ! -L "$sps_repl_target" ]; then
        return 1
    fi
    if [ -d "$sps_repl_source" ] && [ ! -L "$sps_repl_source" ]; then
        return 1
    fi
    { [ -f "$sps_repl_target" ] || [ -L "$sps_repl_target" ]; } || return 1
    [ -f "$sps_repl_source" ] || [ -L "$sps_repl_source" ]
}

# Live discs seed /etc (openssl.cnf, CA certs). Those paths are preserved,
# so they cannot be replaced; they also must not block the install.
# Keep the running copy, take ownership, and write .sps-new when the
# packaged default differs. Owned preserved files still use the upgrade path.
sps_keep_live_unowned_preserved()
{
    sps_keep_path=$1
    sps_keep_target=$2
    sps_is_live_bootstrap || return 1
    sps_is_preserved_path "$sps_keep_path" || return 1
    if [ -d "$sps_keep_target" ] && [ ! -L "$sps_keep_target" ]; then
        return 1
    fi
    sps_exists "$sps_keep_target" || return 1
    if [ -d "$sps_old_record" ] && [ -f "$sps_old_record/hashes" ] &&
       awk -F "$sps_tab" -v path="$sps_keep_path" \
           '$3 == path { found=1 } END { exit !found }' \
           "$sps_old_record/hashes"
    then
        return 1
    fi
    return 0
}

# Write a packaged default beside a kept local file.
sps_install_sps_new()
{
    sps_new_target=$sps_target.sps-new
    if [ -e "$sps_new_target" ] || [ -L "$sps_new_target" ]; then
        if [ -f "$sps_new_target" ] && [ ! -L "$sps_new_target" ] &&
           [ "$(sps_sha256 "$sps_new_target" 2>/dev/null || :)" = "$sps_new_digest" ]; then
            sps_warn "/$sps_path is locally modified; matching .sps-new already exists"
            return 0
        fi
        sps_new_target=$sps_target.sps-new.$sps_version-$sps_release
    fi
    # Never clobber a second existing default that differs from what we would
    # write; leave it for the admin to review and drop instead of losing bytes.
    if [ -e "$sps_new_target" ] || [ -L "$sps_new_target" ]; then
        sps_warn "/$sps_path collision; keeping existing ${sps_new_target#${SPS_ROOT%/}}"
        return 0
    fi
    sps_new_parent=${sps_new_target%/*}
    sps_file_tmp=$(mktemp "$sps_new_parent/.sps-file.XXXXXX") ||
        sps_fail 8 "cannot stage preserved update for /$sps_path"
    if ! cp -p "$sps_source" "$sps_file_tmp"; then
        rm -f "$sps_file_tmp"
        sps_fail 8 "cannot stage preserved update for /$sps_path"
    fi
    if sps_is_root; then
        sps_file_mode=
        if [ -u "$sps_source" ] || [ -g "$sps_source" ]; then
            sps_file_mode=$(sps_mode_of "$sps_source") ||
                sps_fail 4 "cannot determine file mode for /$sps_path"
        fi
        sps_normalize_owner "$sps_file_tmp" ||
            sps_fail 8 "cannot normalize owner of /$sps_path"
        if [ -n "$sps_file_mode" ]; then
            chmod "$sps_file_mode" "$sps_file_tmp" ||
                sps_fail 8 "cannot restore mode on /$sps_path"
        fi
    fi
    mv -f "$sps_file_tmp" "$sps_new_target" || {
        rm -f "$sps_file_tmp"
        sps_fail 8 "cannot write preserved update for /$sps_path"
    }
    sps_warn "/$sps_path is locally modified; kept it and wrote ${sps_new_target#${SPS_ROOT%/}}"
}

while IFS= read -r sps_path || [ -n "$sps_path" ]; do
    sps_target_path "$sps_path"
    if sps_exists "$sps_target"; then
        if sps_payload_matches_existing "$sps_path" "$sps_target"; then
            continue
        fi
        if sps_may_replace_unowned "$sps_path" "$sps_target"; then
            sps_say "replacing unowned live file /$sps_path"
            continue
        fi
        if sps_keep_live_unowned_preserved "$sps_path" "$sps_target"; then
            sps_say "keeping unowned live preserved file /$sps_path"
            continue
        fi
        if sps_is_preserved_path "$sps_path"; then
            sps_fail 7 "/$sps_path exists, is not owned, and is under a preserved prefix"
        fi
        sps_fail 7 "/$sps_path exists and is not owned by an installed package"
    fi
done <"$sps_work/unowned"

while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
    sps_path=${sps_entry%/}
    sps_safe_parents "$sps_path" || sps_fail 7 "unsafe target path /$sps_path"
    sps_target_path "$sps_path"
    case $sps_entry in
        */)
            if sps_exists "$sps_target" &&
               { [ ! -d "$sps_target" ] || [ -L "$sps_target" ]; } &&
               ! sps_is_shared_dir_link "$sps_path"; then
                sps_fail 7 "/$sps_path exists and is not a directory"
            fi
            ;;
        *)
            if [ -d "$sps_target" ] && [ ! -L "$sps_target" ]; then
                sps_fail 7 "/$sps_path is a directory"
            fi
            ;;
    esac
done <"$sps_stage/.SPS/files"

mkdir -p "$SPS_ROOT" || sps_fail 8 "cannot create target root '$SPS_ROOT'"

sps_file_count=$(awk 'END { print NR + 0 }' "$sps_work/owned")
sps_say "$sps_install_action $sps_name $sps_version-$sps_release ($sps_file_count files)"

# Create directories first. Existing directories are shared, not exclusively
# owned by a package.
while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
    case $sps_entry in
        */)
            sps_path=${sps_entry%/}
            sps_safe_parents "$sps_path" || sps_fail 7 "unsafe target path /$sps_path"
            sps_target_path "$sps_path"
            if [ ! -d "$sps_target" ]; then
                mkdir -p "$sps_target" || sps_fail 8 "cannot create /$sps_path"
                sps_source_dir=$sps_stage/$sps_path
                sps_dir_mode=$(sps_mode_of "$sps_source_dir") ||
                    sps_fail 4 "cannot determine directory mode for /$sps_path"
                sps_normalize_owner "$sps_target" ||
                    sps_fail 8 "cannot normalize owner of /$sps_path"
                chmod "$sps_dir_mode" "$sps_target" ||
                    sps_fail 8 "cannot set mode on /$sps_path"
            fi
            ;;
    esac
done <"$sps_stage/.SPS/files"

while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
    case $sps_entry in */) continue ;; esac
    sps_path=$sps_entry
    sps_source=$sps_stage/$sps_path
    sps_safe_stage_parents "$sps_path" || sps_fail 4 "unsafe extracted payload path /$sps_path"
    sps_safe_parents "$sps_path" || sps_fail 7 "unsafe target path /$sps_path"
    sps_target_path "$sps_path"
    sps_parent=${sps_target%/*}
    [ -n "$sps_parent" ] || sps_parent=/
    mkdir -p "$sps_parent" || sps_fail 8 "cannot create parent of /$sps_path"
    sps_verbose_say "/$sps_path"
    if sps_keep_live_unowned_preserved "$sps_path" "$sps_target"; then
        if [ -f "$sps_source" ] && [ ! -L "$sps_source" ] &&
           [ -f "$sps_target" ] && [ ! -L "$sps_target" ]; then
            sps_new_digest=$(sps_sha256 "$sps_source") ||
                sps_fail 6 "cannot hash new protected file /$sps_path"
            sps_cur_digest=$(sps_sha256 "$sps_target") ||
                sps_fail 6 "cannot hash existing protected file /$sps_path"
            if [ "$sps_new_digest" != "$sps_cur_digest" ]; then
                sps_install_sps_new
            fi
        fi
        continue
    fi
    if [ -L "$sps_source" ]; then
        sps_link_tmp=$(sps_mktemp_dir "$sps_parent" link) ||
            sps_fail 8 "cannot stage symlink /$sps_path"
        if ! cp -P "$sps_source" "$sps_link_tmp/item"; then
            rm -rf "$sps_link_tmp"
            sps_fail 8 "cannot copy symlink /$sps_path"
        fi
        if ! mv -f "$sps_link_tmp/item" "$sps_target"; then
            rm -rf "$sps_link_tmp"
            sps_fail 8 "cannot install symlink /$sps_path"
        fi
        rmdir "$sps_link_tmp" 2>/dev/null || :
    else
        # Keep the local file and leave the new packaged copy beside it as .sps-new.
        if sps_old_file_modified "$sps_path" "$sps_target"; then
            sps_old_expected=$(awk -F "$sps_tab" -v path="$sps_path"                 '$3 == path && $1 == "sha256" { print $2; exit }' "$sps_old_record/hashes")
            sps_new_digest=$(sps_sha256 "$sps_source") ||
                sps_fail 6 "cannot hash new protected file /$sps_path"
            if [ "$sps_new_digest" = "$sps_old_expected" ]; then
                sps_warn "/$sps_path is locally modified; packaged default is unchanged, keeping local file"
                continue
            fi
            sps_install_sps_new
            continue
        fi

        sps_file_tmp=$(mktemp "$sps_parent/.sps-file.XXXXXX") ||
            sps_fail 8 "cannot stage file /$sps_path"
        if ! cp -p "$sps_source" "$sps_file_tmp"; then
            rm -f "$sps_file_tmp"
            sps_fail 8 "cannot install file /$sps_path"
        fi
        if sps_is_root; then
            # chown can clear setuid/setgid, so remember the mode only for those files.
            sps_file_mode=
            if [ -u "$sps_source" ] || [ -g "$sps_source" ]; then
                sps_file_mode=$(sps_mode_of "$sps_source") ||
                    sps_fail 4 "cannot determine file mode for /$sps_path"
            fi
            sps_normalize_owner "$sps_file_tmp" ||
                sps_fail 8 "cannot normalize owner of /$sps_path"
            if [ -n "$sps_file_mode" ]; then
                chmod "$sps_file_mode" "$sps_file_tmp" ||
                    sps_fail 8 "cannot restore mode on /$sps_path"
            fi
        fi
        if ! mv -f "$sps_file_tmp" "$sps_target"; then
            rm -f "$sps_file_tmp"
            sps_fail 8 "cannot install file /$sps_path"
        fi
    fi
done <"$sps_stage/.SPS/files"

comm -23 "$sps_work/old-owned" "$sps_work/owned" >"$sps_work/obsolete.candidates"
# Read this package's owner entries once before cleaning obsolete files.
awk -F "$sps_tab" -v package="$sps_name" '$2 == package { print $1 }' \
    "$SPS_DB/owners" | LC_ALL=C sort -u >"$sps_work/current-owned" ||
    sps_fail 11 "cannot inspect current owner index"
comm -12 "$sps_work/obsolete.candidates" "$sps_work/current-owned" >"$sps_work/obsolete"
while IFS= read -r sps_path || [ -n "$sps_path" ]; do
    sps_safe_parents "$sps_path" || sps_fail 7 "unsafe target path /$sps_path"
    sps_target_path "$sps_path"
    if [ -f "$sps_target" ] && [ ! -L "$sps_target" ] &&
       sps_old_file_modified "$sps_path" "$sps_target"; then
        sps_warn "/$sps_path is locally modified and obsolete; keeping it unowned"
        continue
    fi
    if [ -L "$sps_target" ] || [ -f "$sps_target" ]; then
        rm -f "$sps_target" || sps_fail 8 "cannot remove obsolete file /$sps_path"
    fi
done <"$sps_work/obsolete"
if [ -d "$sps_old_record" ]; then
    # Directories are shared. A directory the new version drops must still be
    # preserved if any other installed package still claims it.
    : >"$sps_work/other-dirs"
    for sps_other_record in "$sps_installed"/*; do
        [ -d "$sps_other_record" ] && [ ! -L "$sps_other_record" ] || continue
        sps_other_name=${sps_other_record##*/}
        case $sps_other_name in .|..) continue ;; esac
        [ "$sps_other_name" = "$sps_name" ] && continue
        [ -f "$sps_other_record/files" ] && [ ! -L "$sps_other_record/files" ] || continue
        awk '/\/$/ { print }' "$sps_other_record/files" >>"$sps_work/other-dirs"
    done
    LC_ALL=C sort -u "$sps_work/other-dirs" -o "$sps_work/other-dirs"

    LC_ALL=C sort -r "$sps_old_record/files" >"$sps_work/old-files.reverse"
    while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
        case $sps_entry in
            */)
                sps_path=${sps_entry%/}
                if awk -v wanted="$sps_entry" '$0 == wanted { found=1 } END { exit !found }' \
                    "$sps_work/other-dirs"; then
                    continue
                fi
                if ! awk -v wanted="$sps_path" '
                    { value=$0; sub(/\/$/, "", value); if (value == wanted) found=1 }
                    END { exit !found }
                ' "$sps_stage/.SPS/files"; then
                    sps_safe_parents "$sps_path" || continue
                    sps_target_path "$sps_path"
                    if [ -d "$sps_target" ] && [ ! -L "$sps_target" ]; then
                        rmdir "$sps_target" 2>/dev/null || :
                    fi
                fi
                ;;
        esac
    done <"$sps_work/old-files.reverse"
fi

sps_record_tmp=$(sps_mktemp_dir "$SPS_DB/installed" record) ||
    sps_fail 11 "cannot stage installed package record"
cp "$sps_stage/.SPS/meta" "$sps_record_tmp/meta" &&
cp "$sps_stage/.SPS/files" "$sps_record_tmp/files" &&
cp "$sps_stage/.SPS/hashes" "$sps_record_tmp/hashes" ||
    sps_fail 11 "cannot stage installed package metadata"
if [ -d "$sps_stage/.SPS/hooks" ]; then
    mkdir "$sps_record_tmp/hooks" || sps_fail 11 "cannot stage package hooks"
    for sps_hook in pre-install post-install pre-remove post-remove; do
        [ -f "$sps_stage/.SPS/hooks/$sps_hook" ] || continue
        cp "$sps_stage/.SPS/hooks/$sps_hook" "$sps_record_tmp/hooks/$sps_hook" ||
            sps_fail 11 "cannot stage package hook '$sps_hook'"
    done
fi

sps_owners_base=$(mktemp "$SPS_DB/.owners-base.XXXXXX") || sps_fail 11 "cannot stage owner index"
sps_owners_new=$(mktemp "$SPS_DB/.owners-new.XXXXXX") || sps_fail 11 "cannot stage owner index"
if ! awk -v sps_action=owners-remove -v sps_package="$sps_name" -v sps_program=pkin \
    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/owners" >"$sps_owners_base"; then
    sps_fail 11 "installed ownership database is malformed"
fi
while IFS= read -r sps_path || [ -n "$sps_path" ]; do
    printf '%s\t%s\n' "$sps_path" "$sps_name" >>"$sps_owners_base"
done <"$sps_work/owned"
LC_ALL=C sort "$sps_owners_base" -o "$sps_owners_new" || sps_fail 11 "cannot stage owner index"

sps_world_base=$(mktemp "$SPS_DB/.world-base.XXXXXX") || sps_fail 11 "cannot stage world database"
sps_world_new=$(mktemp "$SPS_DB/.world-new.XXXXXX") || sps_fail 11 "cannot stage world database"
sps_was_explicit=0
if awk -v package="$sps_name" '$0 == package { found=1 } END { exit !found }' "$SPS_DB/world"; then
    sps_was_explicit=1
fi
if ! awk -v sps_action=world-remove -v sps_package="$sps_name" -v sps_program=pkin \
    -f "$SPS_LIBDIR/db.awk" "$SPS_DB/world" >"$sps_world_base"; then
    sps_fail 11 "installed world database is malformed"
fi
if [ "$sps_reason" = explicit ] || [ "$sps_was_explicit" -eq 1 ]; then
    printf '%s\n' "$sps_name" >>"$sps_world_base"
fi
LC_ALL=C sort -u "$sps_world_base" -o "$sps_world_new" || sps_fail 11 "cannot stage world database"

sps_backup_dir=$(sps_mktemp_dir "$SPS_DB/installed" backup) ||
    sps_fail 11 "cannot create database rollback area"
cp -p "$SPS_DB/owners" "$sps_backup_dir/owners" &&
cp -p "$SPS_DB/world" "$sps_backup_dir/world" ||
    sps_fail 11 "cannot preserve database state"
sps_had_old=0
if [ -d "$sps_old_record" ]; then
    mv "$sps_old_record" "$sps_backup_dir/record" || sps_fail 11 "cannot preserve old package record"
    sps_had_old=1
    sps_record_backed_up=1
fi

sps_database_commit_started=1
sps_commit_failed=0
mv "$sps_record_tmp" "$sps_old_record" || sps_commit_failed=1
if [ "$sps_commit_failed" -eq 0 ]; then sps_record_tmp=; fi
if [ "$sps_commit_failed" -eq 0 ]; then
    mv -f "$sps_owners_new" "$SPS_DB/owners" || sps_commit_failed=1
fi
if [ "$sps_commit_failed" -eq 0 ]; then
    mv -f "$sps_world_new" "$SPS_DB/world" || sps_commit_failed=1
fi
if [ "$sps_commit_failed" -ne 0 ]; then
    rm -rf "$sps_old_record"
    if [ "$sps_had_old" -eq 1 ]; then
        mv "$sps_backup_dir/record" "$sps_old_record" 2>/dev/null || :
    fi
    cp "$sps_backup_dir/owners" "$SPS_DB/.owners-restore.$$" 2>/dev/null &&
        mv -f "$SPS_DB/.owners-restore.$$" "$SPS_DB/owners" 2>/dev/null || :
    cp "$sps_backup_dir/world" "$SPS_DB/.world-restore.$$" 2>/dev/null &&
        mv -f "$SPS_DB/.world-restore.$$" "$SPS_DB/world" 2>/dev/null || :
    sps_record_backed_up=0
    sps_database_commit_started=0
    sps_fail 11 "database commit failed; previous metadata was restored"
fi
sps_commit_complete=1

sps_date=$(date '+%Y-%m-%d' 2>/dev/null || printf '%s' unknown)
if [ -n "$sps_old_version" ]; then
    printf '%s upgrade %s %s %s\n' "$sps_date" "$sps_name" "$sps_old_version" "$sps_version" >>"$SPS_DB/history" ||
        sps_warn "could not append package history"
else
    printf '%s install %s %s-%s\n' "$sps_date" "$sps_name" "$sps_version" "$sps_release" >>"$SPS_DB/history" ||
        sps_warn "could not append package history"
fi

if ! sps_run_hook post-install; then
    sps_warn "$sps_install_action completed, but post-install hook failed for '$sps_name'"
    exit 10
fi

# Enable a mapped service for the init recorded in $SPS_ROOT/etc/sps/init.
# Stock scripts are copied when the package did not ship this init's file.
if [ -f "$SPS_LIBDIR/setup/service.sh" ]; then
    # shellcheck disable=SC1091
    . "$SPS_LIBDIR/setup/service.sh"
    setup_service_hook_enable || :
fi

if [ -n "$sps_old_version" ]; then
    printf '%s: upgraded %s %s-%s -> %s-%s\n' "$sps_prog" "$sps_name" \
        "$sps_old_version" "$sps_old_release" "$sps_version" "$sps_release"
else
    printf '%s: installed %s %s-%s\n' "$sps_prog" "$sps_name" "$sps_version" "$sps_release"
fi

exit 0