#!/bin/bash
#
# tayra-pkg - the Tayra binary package manager.
#
# Packages are gzip-compressed tar archives published as assets of one GitHub
# release, described by a single signed index file. There is no server side and
# no API call: an update is a plain HTTPS GET of
#
#   ${TAYRA_REPO_URL}/index
#   ${TAYRA_REPO_URL}/tayra-<name>-<version>-<arch>.tar.gz
#
# The index is signed with Ed25519 and every archive in it is pinned by
# SHA-256, so the index signature covers the whole repository.
#
# Installing never writes through an existing file. Each archive is unpacked
# into a staging directory and its files are then moved into place, so every
# replacement is a rename(2): a running program keeps the inode it started
# with, a shared library is never truncated under the processes that mapped it,
# and an interrupted install cannot leave a half-written binary behind. This is
# also why upgrading tayra-pkg itself while it is running is safe.
#
# Files below /etc are treated as configuration: a locally modified one is kept
# and the packaged version is written beside it as <file>.tpknew.
#
# The "|"-separated metadata is parsed with bash's own read rather than with
# grep, sed, or awk: one process per file instead of one per line, and no
# dependence on which regular-expression dialect the base utilities implement.
# That was a live concern rather than a hypothetical - BA6 matched with Go
# regular expressions, in which "|" is alternation rather than a literal, until
# commit aa9f076.

set -Eeuo pipefail

readonly PROGRAM=tayra-pkg
readonly NEWLINE=$'\n'
readonly CHUNK=256

# Removing any of these from a running system would leave it unable to boot,
# run a shell, or install anything again.
readonly ESSENTIAL=(tayra-base ba6 bash tayra-release)

ROOT=""
ASSUME_YES=0
REINSTALL=0
DOWNLOAD_ONLY=0

# Keyed by path and by package name, so they have to be associative: an indexed
# array would evaluate "etc/nanorc" as arithmetic.
declare -A RECORDED_HASH=()
declare -A VISITED=()
declare -a RESOLVED=()

TAYRA_REPO_URL="https://github.com/c0m4r/tayra/releases/download/repo-x86_64"
TAYRA_REPO_ARCH="x86_64"
TAYRA_REPO_KEY="/etc/tayra/keys/tayra-repo.pub"
TAYRA_REQUIRE_SIGNATURE=1

log()  { printf '==> %s\n' "$*"; }
warn() { printf '==> warning: %s\n' "$*" >&2; }
die()  { printf 'error: %s\n' "$*" >&2; exit 1; }

usage() {
    cat <<'EOF'
usage: tayra-pkg <command> [options] [package...]

commands:
  update                 fetch and verify the repository index
  list [--upgradable]    list installed packages, or only those out of date
  list --available       list everything the repository offers
  info <package>...      show what a package is and where it came from
  files <package>        list the paths a package owns
  owns <path>            report which package owns a path
  install <package>...   install packages and their dependencies
  upgrade [<package>...] bring packages, or the whole system, up to the repository
  remove <package>...    remove packages and their files
  verify [<package>...]  re-check installed files against their recorded hashes
  clean [--all]          drop cached archives the index no longer references

options:
  -y, --yes              do not ask for confirmation
      --reinstall        install again even at the same version
      --download-only    fetch archives into the cache and stop
      --root <dir>       operate on another root filesystem

Configuration lives in /etc/tayra/pkg.conf.
EOF
}

# ---------------------------------------------------------------- environment

set_paths() {
    STATE_DIR="${ROOT}/var/lib/tayra"
    DB_DIR="${STATE_DIR}/db"
    REPO_DIR="${STATE_DIR}/repo"
    LOCK_DIR="${STATE_DIR}/lock"
    CACHE_DIR="${ROOT}/var/cache/tayra/pkg"
    STAGING_DIR="${ROOT}/var/cache/tayra/staging"
    INDEX="${REPO_DIR}/index"
    CONFIG="${ROOT}/etc/tayra/pkg.conf"
}

load_config() {
    if [[ -f "${CONFIG}" ]]; then
        # shellcheck source=/dev/null
        source "${CONFIG}"
    fi
    [[ -n "${TAYRA_REPO_URL}" ]] || die "TAYRA_REPO_URL is not set in ${CONFIG}"
}

# BA6 provides a curl applet and /bin comes first on PATH, but only the real
# curl built for the image speaks HTTPS with the system CA bundle, so it is
# named explicitly rather than resolved through PATH.
select_curl() {
    if [[ -x /usr/bin/curl ]]; then
        CURL=/usr/bin/curl
    else
        CURL="$(command -v curl)" || die "curl is required and was not found"
    fi
}

# Changing the running system needs root. Another root - an image being
# assembled, or a mounted disk - only needs to be writable, which is what makes
# --root usable without privilege.
require_write_access() {
    local action="$1"
    if [[ -z "${ROOT}" ]]; then
        [[ "$(id -u)" -eq 0 ]] || die "${PROGRAM} ${action} must run as root"
        return 0
    fi
    mkdir -p "${STATE_DIR}" 2> /dev/null \
        || die "${PROGRAM} ${action} cannot write to ${ROOT}"
    [[ -w "${STATE_DIR}" ]] || die "${PROGRAM} ${action} cannot write to ${ROOT}"
}

acquire_lock() {
    mkdir -p "${STATE_DIR}"
    if ! mkdir "${LOCK_DIR}" 2> /dev/null; then
        die "another ${PROGRAM} is running (remove ${LOCK_DIR} if it is not)"
    fi
    trap release_lock EXIT
}

release_lock() {
    rmdir "${LOCK_DIR}" 2> /dev/null || true
}

confirm() {
    local answer
    if [[ "${ASSUME_YES}" == 1 ]]; then
        return 0
    fi
    printf '%s [Y/n] ' "$1"
    read -r answer || answer=n
    case "${answer}" in
        '' | y | Y | yes | YES) return 0 ;;
        *) die "aborted at user request" ;;
    esac
}

# Runs a command over a long argument list in chunks, so installing a package
# with thousands of files still costs a handful of processes rather than one
# per file.
run_chunked() {
    local -a command_line=() chunk=()
    local argument
    while [[ $# -gt 0 && "$1" != -- ]]; do
        command_line+=("$1")
        shift
    done
    shift
    for argument in "$@"; do
        chunk+=("${argument}")
        if ((${#chunk[@]} >= CHUNK)); then
            "${command_line[@]}" "${chunk[@]}"
            chunk=()
        fi
    done
    if ((${#chunk[@]} > 0)); then
        "${command_line[@]}" "${chunk[@]}"
    fi
}

# As run_chunked, but for commands whose last operand is a destination, so each
# chunk ends with it: "mv file... directory".
run_chunked_into() {
    local destination="$1"
    shift
    local -a command_line=() chunk=()
    local argument
    while [[ $# -gt 0 && "$1" != -- ]]; do
        command_line+=("$1")
        shift
    done
    shift
    for argument in "$@"; do
        chunk+=("${argument}")
        if ((${#chunk[@]} >= CHUNK)); then
            "${command_line[@]}" "${chunk[@]}" "${destination}"
            chunk=()
        fi
    done
    if ((${#chunk[@]} > 0)); then
        "${command_line[@]}" "${chunk[@]}" "${destination}"
    fi
}

human_size() {
    local bytes="$1"
    if ((bytes >= 1048576)); then
        printf '%s.%s MiB\n' "$((bytes / 1048576))" "$(((bytes % 1048576) * 10 / 1048576))"
    elif ((bytes >= 1024)); then
        printf '%s KiB\n' "$((bytes / 1024))"
    else
        printf '%s B\n' "${bytes}"
    fi
}

file_hash() {
    local sum _rest
    read -r sum _rest < <(sha256sum "$1")
    printf '%s\n' "${sum}"
}

# --------------------------------------------------------------------- index

require_index() {
    [[ -f "${INDEX}" ]] || die "no package index; run '${PROGRAM} update' first"
}

index_names() {
    local name _rest
    while IFS='|' read -r name _rest; do
        if [[ -n "${name}" && "${name}" != \#* ]]; then
            printf '%s\n' "${name}"
        fi
    done < "${INDEX}"
}

# Fills the IDX_* variables from the index. Callers that recurse must copy what
# they need first.
read_index_entry() {
    local wanted="$1"
    local name rest
    while IFS='|' read -r name rest; do
        if [[ -z "${name}" || "${name}" == \#* ]]; then
            continue
        fi
        if [[ "${name}" == "${wanted}" ]]; then
            IFS='|' read -r IDX_VERSION IDX_ARCH IDX_ARCHIVE IDX_SHA256 \
                IDX_SIZE IDX_DEPENDS IDX_DESCRIPTION <<< "${rest}"
            return 0
        fi
    done < "${INDEX}"
    return 1
}

# ------------------------------------------------------------------ database

installed_field() {
    local name="$1"
    local wanted="$2"
    local key value
    [[ -f "${DB_DIR}/${name}/desc" ]] || return 1
    while IFS='=' read -r key value; do
        if [[ "${key}" == "${wanted}" ]]; then
            printf '%s\n' "${value}"
            return 0
        fi
    done < "${DB_DIR}/${name}/desc"
    return 1
}

is_installed() {
    [[ -f "${DB_DIR}/$1/desc" ]]
}

installed_names() {
    local entry name
    [[ -d "${DB_DIR}" ]] || return 0
    for entry in "${DB_DIR}"/*; do
        [[ -f "${entry}/desc" ]] || continue
        name="${entry##*/}"
        printf '%s\n' "${name}"
    done
}

# Every package that lists the argument among its dependencies.
installed_dependents() {
    local wanted="$1"
    local name depends dependency
    local -a dependency_list=()
    while IFS= read -r name; do
        depends="$(installed_field "${name}" depends)" || continue
        if [[ "${depends}" == - || -z "${depends}" ]]; then
            continue
        fi
        IFS=, read -r -a dependency_list <<< "${depends}"
        for dependency in "${dependency_list[@]}"; do
            if [[ "${dependency}" == "${wanted}" ]]; then
                printf '%s\n' "${name}"
            fi
        done
    done < <(installed_names)
}

# ------------------------------------------------------------------ fetching

fetch() {
    local url="$1"
    local output="$2"
    local -a options=(--fail --location --retry 3 --output "${output}")
    if [[ -t 2 ]]; then
        options+=(--progress-bar)
    else
        options+=(--silent --show-error)
    fi
    "${CURL}" "${options[@]}" "${url}" \
        || die "could not fetch ${url}"
}

verify_signature() {
    local file="$1"
    local signature="$2"
    local key="${ROOT}${TAYRA_REPO_KEY}"

    if [[ "${TAYRA_REQUIRE_SIGNATURE}" != 1 ]]; then
        warn "signature checking is disabled in ${CONFIG}"
        return 0
    fi
    [[ -f "${key}" ]] \
        || die "no repository key at ${key}; the image was built without one"
    command -v openssl > /dev/null 2>&1 \
        || die "openssl is required to verify the repository index"
    openssl pkeyutl -verify -rawin -pubin -inkey "${key}" \
        -in "${file}" -sigfile "${signature}" > /dev/null 2>&1 \
        || die "the repository index is not signed by ${key}"
}

# ------------------------------------------------------------------ manifests

# The paths a manifest owns, directories excluded: those are shared between
# packages and are never owned exclusively.
manifest_paths() {
    local type _mode _value _size path
    while IFS='|' read -r type _mode _value _size path; do
        if [[ "${type}" != d ]]; then
            printf '%s\n' "${path}"
        fi
    done < "$1"
}

# Deepest first, so a directory is only considered once its children are gone.
manifest_directories() {
    local type _mode _value _size path
    while IFS='|' read -r type _mode _value _size path; do
        if [[ "${type}" == d ]]; then
            printf '%s\n' "${ROOT}/${path}"
        fi
    done < "$1" | sort -r
}

config_protected() {
    [[ "$1" == etc/* ]]
}

# The hashes the installed manifest recorded for this package's configuration
# files, so a local edit can be told apart from an untouched file.
load_recorded_config_hashes() {
    local manifest="${DB_DIR}/$1/files"
    local type _mode value _size path
    RECORDED_HASH=()
    [[ -f "${manifest}" ]] || return 0
    while IFS='|' read -r type _mode value _size path; do
        if [[ "${type}" == f ]] && config_protected "${path}"; then
            RECORDED_HASH["${path}"]="${value}"
        fi
    done < "${manifest}"
}

# Whether the copy on disk is the administrator's rather than the package's:
# either it was edited since the package placed it, or it was there first and no
# package ever claimed it. Both are kept.
keep_local_config() {
    local path="$1"
    local target="$2"
    local recorded
    config_protected "${path}" || return 1
    [[ -f "${target}" ]] || return 1
    recorded="${RECORDED_HASH[${path}]:-}"
    [[ -n "${recorded}" ]] || return 0
    [[ "$(file_hash "${target}")" != "${recorded}" ]]
}

# Moves a staged tree into place. Directories are created first, then files and
# symbolic links are renamed in, grouped by destination directory so a package
# with thousands of files costs a few hundred processes rather than thousands.
apply_manifest() {
    local name="$1"
    local staging="$2"
    local manifest="$3"
    local type mode value _size path target parent kept=0
    local -a directories=() sources=() targets=()
    local -A batches=() modes=()

    while IFS='|' read -r type mode value _size path; do
        if [[ "${type}" == d ]]; then
            directories+=("${ROOT}/${path}")
            modes["${mode}"]+="${ROOT}/${path}${NEWLINE}"
        fi
    done < "${manifest}"
    if ((${#directories[@]} > 0)); then
        run_chunked mkdir -p -- "${directories[@]}"
    fi

    load_recorded_config_hashes "${name}"
    while IFS='|' read -r type mode value _size path; do
        if [[ "${type}" == d ]]; then
            continue
        fi
        target="${ROOT}/${path}"
        parent="${target%/*}"
        if [[ ! -d "${parent}" ]]; then
            mkdir -p "${parent}"
        fi
        if [[ "${type}" == f ]] && keep_local_config "${path}" "${target}"; then
            mv "${staging}/${path}" "${target}.tpknew"
            chmod "${mode}" "${target}.tpknew"
            warn "kept your ${path}; the packaged version is ${path}.tpknew"
            kept=$((kept + 1))
            continue
        fi
        batches["${parent}"]+="${staging}/${path}${NEWLINE}"
        if [[ "${type}" == f ]]; then
            modes["${mode}"]+="${target}${NEWLINE}"
        fi
    done < "${manifest}"

    for parent in "${!batches[@]}"; do
        mapfile -t sources <<< "${batches[${parent}]}"
        # The here-string adds a newline of its own, leaving one empty field.
        unset 'sources[-1]'
        run_chunked_into "${parent}" mv -f -- "${sources[@]}"
    done

    # A rename carries the mode across, but a staging directory on another
    # filesystem turns the rename into a copy, so the modes are asserted.
    for mode in "${!modes[@]}"; do
        mapfile -t targets <<< "${modes[${mode}]}"
        unset 'targets[-1]'
        run_chunked chmod "${mode}" -- "${targets[@]}"
    done

    if ((kept > 0)); then
        warn "${kept} configuration file(s) were left as they are"
    fi
}

# Files the previous version owned and the new one does not.
prune_removed_files() {
    local name="$1"
    local new_manifest="$2"
    local old_manifest="${DB_DIR}/${name}/files"
    local path
    local -a obsolete=() directories=()
    local -A kept=()

    [[ -f "${old_manifest}" ]] || return 0

    while IFS= read -r path; do
        kept["${path}"]=1
    done < <(manifest_paths "${new_manifest}")

    while IFS= read -r path; do
        if [[ -z "${kept[${path}]:-}" ]]; then
            obsolete+=("${ROOT}/${path}")
        fi
    done < <(manifest_paths "${old_manifest}")

    if ((${#obsolete[@]} > 0)); then
        log "removing ${#obsolete[@]} obsolete file(s)"
        run_chunked rm -f -- "${obsolete[@]}"
    fi

    # Directories are only removed when nothing else is left in them, so one
    # shared with another package survives.
    mapfile -t directories < <(manifest_directories "${old_manifest}")
    for path in "${directories[@]}"; do
        rmdir "${path}" 2> /dev/null || true
    done
}

# Refuses to write over a file another package owns. A package upgrading itself
# is the one case where the paths are expected to be taken already.
check_conflicts() {
    local name="$1"
    local manifest="$2"
    local other path
    local -A claimed=()

    while IFS= read -r other; do
        if [[ "${other}" == "${name}" ]]; then
            continue
        fi
        while IFS= read -r path; do
            claimed["${path}"]="${other}"
        done < <(manifest_paths "${DB_DIR}/${other}/files")
    done < <(installed_names)

    while IFS= read -r path; do
        if [[ -n "${claimed[${path}]:-}" ]]; then
            die "/${path} is already owned by ${claimed[${path}]}"
        fi
        # A path no package claims but that exists anyway was put there by
        # hand; overwriting it silently would lose someone's work. Under /etc
        # that is ordinary - it is configuration, and apply_manifest keeps it
        # and writes the packaged version beside it instead.
        if ! is_installed "${name}" && ! config_protected "${path}" \
            && { [[ -e "${ROOT}/${path}" ]] || [[ -L "${ROOT}/${path}" ]]; }; then
            die "/${path} exists but belongs to no package; move it aside first"
        fi
    done < <(manifest_paths "${manifest}")
}

# ------------------------------------------------------------------ commands

cmd_update() {
    require_write_access update
    acquire_lock
    mkdir -p "${REPO_DIR}"

    log "fetching ${TAYRA_REPO_URL}/index"
    fetch "${TAYRA_REPO_URL}/index" "${REPO_DIR}/index.new"
    fetch "${TAYRA_REPO_URL}/index.sig" "${REPO_DIR}/index.sig.new"
    verify_signature "${REPO_DIR}/index.new" "${REPO_DIR}/index.sig.new"

    local name version arch rest count=0
    while IFS='|' read -r name version arch rest; do
        if [[ -z "${name}" || "${name}" == \#* ]]; then
            continue
        fi
        [[ "${arch}" == "${TAYRA_REPO_ARCH}" ]] \
            || die "the index offers ${name} for ${arch}, but this system is ${TAYRA_REPO_ARCH}"
        count=$((count + 1))
    done < "${REPO_DIR}/index.new"
    ((count > 0)) || die "the repository index is empty"

    mv "${REPO_DIR}/index.new" "${INDEX}"
    mv "${REPO_DIR}/index.sig.new" "${INDEX}.sig"
    log "${count} packages available"

    local upgradable
    upgradable="$(cmd_list --upgradable | wc -l)"
    if ((upgradable > 0)); then
        log "${upgradable} installed package(s) are out of date; run '${PROGRAM} upgrade'"
    else
        log "everything installed is up to date"
    fi
}

cmd_list() {
    local mode="${1:---installed}"
    local name installed available

    case "${mode}" in
        --available)
            require_index
            while IFS= read -r name; do
                read_index_entry "${name}"
                printf '%-18s %-14s %s\n' \
                    "${name}" "${IDX_VERSION}" "${IDX_DESCRIPTION}"
            done < <(index_names)
            ;;
        --upgradable)
            [[ -f "${INDEX}" ]] || return 0
            while IFS= read -r name; do
                installed="$(installed_field "${name}" pkgver)" || continue
                read_index_entry "${name}" || continue
                if [[ "${installed}" != "${IDX_VERSION}" ]]; then
                    printf '%-18s %s -> %s\n' "${name}" "${installed}" "${IDX_VERSION}"
                fi
            done < <(installed_names)
            ;;
        --installed)
            while IFS= read -r name; do
                installed="$(installed_field "${name}" pkgver)"
                available=""
                if [[ -f "${INDEX}" ]] && read_index_entry "${name}"; then
                    if [[ "${IDX_VERSION}" != "${installed}" ]]; then
                        available="  (${IDX_VERSION} available)"
                    fi
                fi
                printf '%-18s %s%s\n' "${name}" "${installed}" "${available}"
            done < <(installed_names)
            ;;
        *) die "unknown option for list: ${mode}" ;;
    esac
}

cmd_info() {
    local name key value
    (($# > 0)) || die "info needs a package name"
    for name in "$@"; do
        if is_installed "${name}"; then
            printf 'installed:\n'
            while IFS='=' read -r key value; do
                printf '  %-12s %s\n' "${key}" "${value}"
            done < "${DB_DIR}/${name}/desc"
            printf '  %-12s %s\n' files \
                "$(wc -l < "${DB_DIR}/${name}/files") paths"
        fi
        if [[ -f "${INDEX}" ]] && read_index_entry "${name}"; then
            printf 'repository:\n'
            printf '  %-12s %s\n' pkgver "${IDX_VERSION}"
            printf '  %-12s %s\n' arch "${IDX_ARCH}"
            printf '  %-12s %s\n' depends "${IDX_DEPENDS}"
            printf '  %-12s %s\n' archive "${IDX_ARCHIVE}"
            printf '  %-12s %s\n' download "$(human_size "${IDX_SIZE}")"
            printf '  %-12s %s\n' description "${IDX_DESCRIPTION}"
        elif ! is_installed "${name}"; then
            die "unknown package: ${name}"
        fi
    done
}

cmd_files() {
    local name="${1:-}"
    [[ -n "${name}" ]] || die "files needs a package name"
    is_installed "${name}" || die "${name} is not installed"
    manifest_paths "${DB_DIR}/${name}/files"
}

cmd_owns() {
    local wanted="${1:-}"
    local name path found=0
    [[ -n "${wanted}" ]] || die "owns needs a path"
    wanted="${wanted#/}"
    while IFS= read -r name; do
        while IFS= read -r path; do
            if [[ "${path}" == "${wanted}" ]]; then
                printf '%s owns /%s\n' "${name}" "${path}"
                found=1
            fi
        done < <(manifest_paths "${DB_DIR}/${name}/files")
    done < <(installed_names)
    ((found == 1)) || die "no installed package owns /${wanted}"
}

# Depth-first over the dependency graph. RESOLVED ends up in install order.
resolve() {
    local name="$1"
    local depends dependency
    if [[ -n "${VISITED[${name}]:-}" ]]; then
        return 0
    fi
    VISITED["${name}"]=1
    read_index_entry "${name}" || die "unknown package: ${name}"
    depends="${IDX_DEPENDS}"
    if [[ "${depends}" != - && -n "${depends}" ]]; then
        local -a dependency_list=()
        IFS=, read -r -a dependency_list <<< "${depends}"
        for dependency in "${dependency_list[@]}"; do
            resolve "${dependency}"
        done
    fi
    RESOLVED+=("${name}")
}

# Downloads an archive into the cache and checks it against the index.
fetch_archive() {
    local name="$1"
    read_index_entry "${name}"
    local archive="${CACHE_DIR}/${IDX_ARCHIVE}"
    local expected="${IDX_SHA256}"

    if [[ -f "${archive}" && "$(file_hash "${archive}")" == "${expected}" ]]; then
        return 0
    fi
    mkdir -p "${CACHE_DIR}"
    log "downloading ${IDX_ARCHIVE} ($(human_size "${IDX_SIZE}"))"
    fetch "${TAYRA_REPO_URL}/${IDX_ARCHIVE}" "${archive}.part"
    if [[ "$(file_hash "${archive}.part")" != "${expected}" ]]; then
        rm -f "${archive}.part"
        die "checksum mismatch for ${IDX_ARCHIVE}"
    fi
    mv "${archive}.part" "${archive}"
}

install_archive() {
    local name="$1"
    read_index_entry "${name}"
    local archive="${CACHE_DIR}/${IDX_ARCHIVE}"
    local version="${IDX_VERSION}"
    local staging="${STAGING_DIR}/${name}"
    local key value staged_name staged_version

    rm -rf "${staging}"
    mkdir -p "${staging}"
    tar -xzf "${archive}" -C "${staging}"

    [[ -f "${staging}/.PKGINFO" && -f "${staging}/.FILES" ]] \
        || die "${IDX_ARCHIVE} is not a Tayra package"
    while IFS='=' read -r key value; do
        case "${key}" in
            pkgname) staged_name="${value}" ;;
            pkgver) staged_version="${value}" ;;
        esac
    done < "${staging}/.PKGINFO"
    [[ "${staged_name}" == "${name}" ]] \
        || die "${IDX_ARCHIVE} contains ${staged_name}, not ${name}"
    [[ "${staged_version}" == "${version}" ]] \
        || die "${IDX_ARCHIVE} contains ${staged_name} ${staged_version}, not ${version}"

    check_conflicts "${name}" "${staging}/.FILES"
    apply_manifest "${name}" "${staging}" "${staging}/.FILES"
    prune_removed_files "${name}" "${staging}/.FILES"

    mkdir -p "${DB_DIR}/${name}"
    {
        cat "${staging}/.PKGINFO"
        printf 'installdate=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
        printf 'origin=%s\n' "${TAYRA_REPO_URL}"
    } > "${DB_DIR}/${name}/desc.new"
    mv "${DB_DIR}/${name}/desc.new" "${DB_DIR}/${name}/desc"
    cp "${staging}/.FILES" "${DB_DIR}/${name}/files.new"
    mv "${DB_DIR}/${name}/files.new" "${DB_DIR}/${name}/files"

    rm -rf "${staging}"
}

# Shared by install and upgrade: work out what to do, show it, then do it.
run_transaction() {
    local -a requested=("$@")
    local name installed action total=0
    local -a plan=() actions=()

    require_index
    RESOLVED=()
    VISITED=()
    for name in "${requested[@]}"; do
        resolve "${name}"
    done

    for name in "${RESOLVED[@]}"; do
        read_index_entry "${name}"
        installed="$(installed_field "${name}" pkgver)" || installed=""
        if [[ -z "${installed}" ]]; then
            action="install ${IDX_VERSION}"
        elif [[ "${installed}" != "${IDX_VERSION}" ]]; then
            action="upgrade ${installed} -> ${IDX_VERSION}"
        elif [[ "${REINSTALL}" == 1 ]] && in_list "${name}" "${requested[@]}"; then
            action="reinstall ${IDX_VERSION}"
        else
            continue
        fi
        plan+=("${name}")
        actions+=("${action}")
        total=$((total + IDX_SIZE))
    done

    if ((${#plan[@]} == 0)); then
        log "nothing to do"
        return 0
    fi

    printf '\n'
    local index
    for index in "${!plan[@]}"; do
        printf '  %-18s %s\n' "${plan[${index}]}" "${actions[${index}]}"
    done
    printf '\ntotal download: %s\n\n' "$(human_size "${total}")"
    confirm "Proceed?"

    for name in "${plan[@]}"; do
        fetch_archive "${name}"
    done
    if [[ "${DOWNLOAD_ONLY}" == 1 ]]; then
        log "archives are in ${CACHE_DIR}; nothing was installed"
        return 0
    fi
    for name in "${plan[@]}"; do
        log "installing ${name}"
        install_archive "${name}"
    done
    log "${#plan[@]} package(s) done"
}

in_list() {
    local wanted="$1"
    shift
    local candidate
    for candidate in "$@"; do
        if [[ "${candidate}" == "${wanted}" ]]; then
            return 0
        fi
    done
    return 1
}

cmd_install() {
    (($# > 0)) || die "install needs a package name"
    require_write_access install
    acquire_lock
    run_transaction "$@"
}

cmd_upgrade() {
    require_write_access upgrade
    acquire_lock
    require_index

    local -a targets=()
    local name installed missing=0

    if (($# > 0)); then
        targets=("$@")
    else
        while IFS= read -r name; do
            installed="$(installed_field "${name}" pkgver)" || continue
            if read_index_entry "${name}" && [[ "${installed}" != "${IDX_VERSION}" ]]; then
                targets+=("${name}")
            fi
        done < <(installed_names)

        while IFS= read -r name; do
            if ! is_installed "${name}"; then
                missing=$((missing + 1))
            fi
        done < <(index_names)
    fi

    if ((${#targets[@]} == 0)); then
        log "the system is up to date"
    else
        run_transaction "${targets[@]}"
    fi
    if ((missing > 0)); then
        log "${missing} package(s) in the repository are not installed"
        log "see '${PROGRAM} list --available'"
    fi
}

cmd_remove() {
    (($# > 0)) || die "remove needs a package name"
    require_write_access remove
    acquire_lock

    local name path dependents kept=0
    local -a obsolete=() directories=()

    for name in "$@"; do
        is_installed "${name}" || die "${name} is not installed"
        if in_list "${name}" "${ESSENTIAL[@]}"; then
            die "${name} is part of the base system and cannot be removed"
        fi
        dependents="$(installed_dependents "${name}" | tr '\n' ' ')"
        dependents="${dependents% }"
        if [[ -n "${dependents}" ]]; then
            die "${name} is required by: ${dependents}"
        fi
    done

    printf '\n'
    for name in "$@"; do
        printf '  %-18s remove %s\n' "${name}" "$(installed_field "${name}" pkgver)"
    done
    printf '\n'
    confirm "Proceed?"

    for name in "$@"; do
        load_recorded_config_hashes "${name}"
        obsolete=()
        while IFS= read -r path; do
            if keep_local_config "${path}" "${ROOT}/${path}"; then
                warn "kept your ${path}"
                kept=$((kept + 1))
                continue
            fi
            obsolete+=("${ROOT}/${path}")
        done < <(manifest_paths "${DB_DIR}/${name}/files")
        if ((${#obsolete[@]} > 0)); then
            run_chunked rm -f -- "${obsolete[@]}"
        fi

        mapfile -t directories < <(manifest_directories "${DB_DIR}/${name}/files")
        for path in "${directories[@]}"; do
            rmdir "${path}" 2> /dev/null || true
        done

        rm -rf "${DB_DIR:?}/${name}"
        log "removed ${name}"
    done
}

cmd_verify() {
    local -a targets=()
    local name type mode value _size path target
    local problems=0 checked=0

    if (($# > 0)); then
        targets=("$@")
    else
        mapfile -t targets < <(installed_names)
    fi

    for name in "${targets[@]}"; do
        is_installed "${name}" || die "${name} is not installed"
        local -A expected=()
        local -a files=()
        while IFS='|' read -r type mode value _size path; do
            target="${ROOT}/${path}"
            case "${type}" in
                d)
                    if [[ ! -d "${target}" ]]; then
                        printf 'missing directory  %s (%s)\n' "/${path}" "${name}"
                        problems=$((problems + 1))
                    fi
                    ;;
                l)
                    if [[ ! -L "${target}" ]]; then
                        printf 'missing link       %s (%s)\n' "/${path}" "${name}"
                        problems=$((problems + 1))
                    elif [[ "$(readlink "${target}")" != "${value}" ]]; then
                        printf 'link changed       %s (%s)\n' "/${path}" "${name}"
                        problems=$((problems + 1))
                    fi
                    ;;
                f)
                    if [[ ! -f "${target}" ]]; then
                        printf 'missing file       %s (%s)\n' "/${path}" "${name}"
                        problems=$((problems + 1))
                    else
                        expected["${target}"]="${value}"
                        files+=("${target}")
                    fi
                    ;;
            esac
        done < "${DB_DIR}/${name}/files"

        if ((${#files[@]} > 0)); then
            local sum actual
            while read -r sum actual; do
                checked=$((checked + 1))
                if [[ "${sum}" != "${expected[${actual}]:-}" ]]; then
                    path="${actual#"${ROOT}"/}"
                    if config_protected "${path}"; then
                        printf 'modified config    /%s (%s)\n' "${path}" "${name}"
                    else
                        printf 'checksum mismatch  /%s (%s)\n' "${path}" "${name}"
                        problems=$((problems + 1))
                    fi
                fi
            done < <(run_chunked sha256sum -- "${files[@]}")
        fi
    done

    log "checked ${checked} file(s) in ${#targets[@]} package(s)"
    if ((problems > 0)); then
        die "${problems} problem(s) found"
    fi
    log "no problems found"
}

cmd_clean() {
    require_write_access clean
    local mode="${1:-}"
    local archive name keep removed=0
    local -A wanted=()

    [[ -d "${CACHE_DIR}" ]] || return 0
    if [[ "${mode}" != --all && -f "${INDEX}" ]]; then
        while IFS= read -r name; do
            read_index_entry "${name}"
            wanted["${IDX_ARCHIVE}"]=1
        done < <(index_names)
    fi
    for archive in "${CACHE_DIR}"/*; do
        [[ -f "${archive}" ]] || continue
        keep="${wanted[${archive##*/}]:-}"
        if [[ -z "${keep}" ]]; then
            rm -f "${archive}"
            removed=$((removed + 1))
        fi
    done
    log "removed ${removed} cached archive(s)"
}

# ---------------------------------------------------------------------- main

main() {
    local command=""
    local -a operands=()

    while (($# > 0)); do
        case "$1" in
            -y | --yes) ASSUME_YES=1 ;;
            --reinstall) REINSTALL=1 ;;
            --download-only) DOWNLOAD_ONLY=1 ;;
            --root)
                shift
                [[ $# -gt 0 ]] || die "--root needs a directory"
                ROOT="${1%/}"
                ;;
            -h | --help | help) command=help ;;
            --installed | --upgradable | --available | --all) operands+=("$1") ;;
            -*) die "unknown option: $1" ;;
            *)
                if [[ -z "${command}" ]]; then
                    command="$1"
                else
                    operands+=("$1")
                fi
                ;;
        esac
        shift
    done

    set_paths
    load_config
    select_curl

    case "${command}" in
        update) cmd_update ;;
        list) cmd_list "${operands[@]}" ;;
        info) cmd_info "${operands[@]}" ;;
        files) cmd_files "${operands[@]}" ;;
        owns) cmd_owns "${operands[@]}" ;;
        install) cmd_install "${operands[@]}" ;;
        upgrade) cmd_upgrade "${operands[@]}" ;;
        remove) cmd_remove "${operands[@]}" ;;
        verify) cmd_verify "${operands[@]}" ;;
        clean) cmd_clean "${operands[@]}" ;;
        help) usage ;;
        '')
            usage
            exit 1
            ;;
        *) die "unknown command: ${command} (try '${PROGRAM} help')" ;;
    esac
}

main "$@"
