#!/bin/bash
#
# sowa-install - install Sowa Linux from a system that is not Sowa.
#
# This is sowa-setup, sowa-bootstrap and sowa-chroot in one file, generated by
# scripts/make-installer-bundle.sh from sowa 0.1. The three
# programs are embedded below exactly as the image ships them; this script
# unpacks them into a temporary directory, runs the one asked for, and removes
# them again.
#
#   sowa-install bootstrap --from-tarball sowa-0.1-x86_64-rootfs.tar.xz /mnt
#   sowa-install chroot /mnt grub-install --target=i386-pc /dev/sda
#   sowa-install chroot /mnt grub-mkconfig -o /boot/grub/grub.cfg
#   sowa-install setup /dev/sda
#
# 'bootstrap' installs into a filesystem you have already mounted and leaves the
# partitioning and the boot loader to you. 'chroot' enters the result with
# /proc, /sys, /dev and /run in place, which is what grub-install and
# grub-mkconfig need to work. 'setup' partitions and installs a whole disk on
# its own, and only works when run from a booted Sowa system, since it copies
# the running root.
#
# Outside a running Sowa system, 'bootstrap --from-tarball' is the one to use:
# there is no live Sowa root to copy, so the rootfs tarball is the source.
#
# It needs bash, tar, xz and root. It requires Linux 6.1 or newer, and
# says so rather than half-working, because Sowa's binaries are built against a
# glibc that assumes it.

set -Eeuo pipefail

readonly SOWA_INSTALL_DISTRO_VERSION="0.1"

usage() {
    printf 'usage: %s COMMAND [ARGUMENT...]\n\n' "${0##*/}"
    printf 'Commands:\n'
    printf '  bootstrap   install into a filesystem you have already mounted\n'
    printf '  chroot      enter an installed Sowa system with /proc, /sys, /dev, /run\n'
    printf '  setup       partition and install a whole disk (from a booted Sowa only)\n'
    printf '  version     print the version this bundle was made from\n\n'
    printf 'Each command takes the arguments of the program it runs; pass --help\n'
    printf 'after the command to see them:\n\n'
    printf '  %s bootstrap --help\n\n' "${0##*/}"
    printf 'Portable installation onto a disk, start to finish:\n\n'
    printf '  %s bootstrap --from-tarball sowa-rootfs.tar.xz /mnt\n' "${0##*/}"
    printf '  %s chroot /mnt grub-install --target=i386-pc /dev/sda\n' "${0##*/}"
    printf '  %s chroot /mnt grub-mkconfig -o /boot/grub/grub.cfg\n' "${0##*/}"
    exit "${1:-1}"
}

# The four files, written back out as they are in the image. The caller owns the
# directory and removes it; nothing here outlives the run.
unpack() {
    local directory="$1"
    mkdir -p "${directory}/lib" "${directory}/sbin"
cat > "${directory}/lib/install-functions" <<'__SOWA_INSTALL_PAYLOAD__'
#!/bin/bash
#
# /usr/lib/sowa/install-functions - what sowa-setup, sowa-bootstrap and
# sowa-chroot all source.
#
# Laying the live root down onto a mounted filesystem is the same job whether
# the caller partitioned the disk first (sowa-setup) or the user did it by hand
# (sowa-bootstrap), so it is written once, here. The three programs differ in
# what they do around it, not in the copy itself - which matters because the
# copy is the part that cannot be checked by reading it, and one implementation
# is one thing to prove.
#
# Every function that writes takes the destination as its first argument. None
# of them reads a SOWA_* variable: each program owns its own names for those
# and passes the values in.

# shellcheck shell=bash

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

require_root() {
    [[ "$(id -u)" -eq 0 ]] || die "${0##*/} must run as root"
}

# The kernel these programs need under them, which is not the same as the kernel
# the installed system will boot. glibc was configured --enable-kernel=6.1.0, so
# every binary in the image carries "Linux 6.1.0" in its .note.ABI-tag and is
# entitled to assume every syscall that kernel had.
#
# This matters because installing from a hosting provider's rescue environment
# runs Sowa's own tar, grub-install and grub-mkconfig on *that* system's kernel,
# and rescue images may carry a kernel below the supported floor.
#
# The failure it produces is not a clean one, which is the reason for checking
# rather than letting it happen: the loader runs the binaries anyway, and then
# statx(2) - added in Linux 4.11, and what coreutils 9 calls directly for ls,
# stat and du - returns ENOSYS. ls prints "Function not implemented" and a row
# of question marks where the permissions go, and anything that reads a file
# listing quietly gets nothing. Sowa cannot fix that from userspace; the only
# honest answer is to say so and stop.
readonly SOWA_MIN_KERNEL_MAJOR=6
readonly SOWA_MIN_KERNEL_MINOR=1

require_supported_host_kernel() {
    local release major minor
    release="$(uname -r)"
    major="${release%%.*}"
    minor="${release#*.}"
    minor="${minor%%.*}"

    # A release string that does not begin with two numbers is not something to
    # guess about. Let it through rather than refuse an install over a version
    # scheme this does not recognise.
    [[ "${major}" =~ ^[0-9]+$ && "${minor}" =~ ^[0-9]+$ ]] || return 0

    if (( major > SOWA_MIN_KERNEL_MAJOR )) \
        || { (( major == SOWA_MIN_KERNEL_MAJOR )) && (( minor >= SOWA_MIN_KERNEL_MINOR )); }; then
        return 0
    fi

    if [[ "${SOWA_INSTALL_IGNORE_KERNEL:-}" == 1 ]]; then
        warn "this kernel is ${release}; Sowa's programs need ${SOWA_MIN_KERNEL_MAJOR}.${SOWA_MIN_KERNEL_MINOR} or newer"
        warn "continuing because SOWA_INSTALL_IGNORE_KERNEL=1 - expect coreutils to fail with ENOSYS"
        return 0
    fi

    printf 'error: this system is running Linux %s, and Sowa'"'"'s programs need %s.%s or newer.\n\n' \
        "${release}" "${SOWA_MIN_KERNEL_MAJOR}" "${SOWA_MIN_KERNEL_MINOR}" >&2
    printf 'Every binary in the Sowa image was built against glibc configured for\n' >&2
    printf 'Linux %s.%s. Running them here does not fail cleanly - it fails quietly:\n' \
        "${SOWA_MIN_KERNEL_MAJOR}" "${SOWA_MIN_KERNEL_MINOR}" >&2
    printf 'statx(2) arrived in Linux 4.11 and coreutils 9 calls it directly, so\n' >&2
    printf '"ls -l" answers "Function not implemented" and prints question marks\n' >&2
    printf 'where the permissions belong, and grub-mkconfig writes a config with no\n' >&2
    printf 'menu entries in it.\n\n' >&2
    printf 'This is the kernel you are installing *from*. The installed system boots\n' >&2
    printf 'its own kernel and is unaffected - so the fix is to install from a newer\n' >&2
    printf 'environment, not to change anything about Sowa:\n\n' >&2
    printf '  - if this is a hosting rescue system, pick a newer rescue image\n' >&2
    printf '  - or boot the Sowa ISO and use sowa-setup or sowa-bootstrap from it\n\n' >&2
    printf 'SOWA_INSTALL_IGNORE_KERNEL=1 proceeds anyway, and is not recommended.\n' >&2
    exit 1
}

# Reports the tools in "$@" that are not on PATH, one per line. The caller
# decides what to do about them, because each program needs a different set and
# some of those are conditional.
missing_tools() {
    local tool
    for tool in "$@"; do
        command -v "${tool}" >/dev/null 2>&1 || printf '%s\n' "${tool}"
    done
}

live_root_mib() {
    # How much the copy is going to write, in MiB. It is measured by walking the
    # tree rather than by asking df, because neither filesystem the live root is
    # made of can answer the question:
    #
    #   /                is an overlay, and df reports an overlay from its
    #                    upper layer - the tmpfs holding this boot's handful of
    #                    writes - so it answers with tens of megabytes for a
    #                    system close to a gigabyte.
    #   /run/sowa/sfs    is the squashfs underneath, and df reports a squashfs
    #                    by the size of the *compressed* image, which is a
    #                    third of what lands on the disk.
    #
    # Either answer would pass a disk that the copy then fills part way
    # through. "du -sx /" is slower - it is a walk of a hundred thousand files
    # - and it is the size of the thing being copied, which is the number this
    # check needs. -x keeps it on the root filesystem, so /proc, /sys, /dev and
    # /run are skipped much as copy_system skips them. It is equally correct on
    # an installed system, where / is an ordinary disk.
    local used
    used="$(du -sxk / 2>/dev/null | awk 'NR == 1 { print $1 }')"
    # A conservative fallback keeps the size check useful if du cannot report it.
    [[ "${used}" =~ ^[0-9]+$ ]] || used=$(( 1024 * 1024 ))
    printf '%s\n' "$(( used / 1024 ))"
}

# The same figure live_root_mib measures for the running system, measured for a
# rootfs tarball instead: the sum of the regular-file sizes it will write, in
# MiB. It is listed from the archive rather than estimated from its compressed
# size, which says nothing reliable about how much filesystem space it needs.
tarball_root_mib() {
    local tarball="${1:?tarball_root_mib needs a tarball}"
    local bytes
    bytes="$(tar -tvf "${tarball}" 2>/dev/null \
        | awk '$1 ~ /^-/ { s += $3 } END { printf "%d\n", s }')"
    [[ "${bytes}" =~ ^[0-9]+$ ]] || bytes=$(( 1024 * 1024 ))
    printf '%s\n' "$(( bytes / 1048576 ))"
}

# One absolute path with no trailing slash, no "..", and no symbolic link left
# in it. Everything below needs the destination in this form: the tar exclude
# has to match the name tar itself will walk, and a message naming the path the
# user typed rather than the one being written to would be a lie.
normalize_path() {
    ( cd "$1" 2>/dev/null && pwd -P )
}

# The live root, copied onto the directory named by $1, which the caller has
# already mounted and passed through normalize_path.
copy_system() {
    local destination="${1:?copy_system needs a destination}"

    # Every exclude below is relative to the root being archived, so a
    # destination of "/" would exclude the whole tree and produce an empty
    # copy. No caller should get here with one; this is the backstop.
    [[ "${destination}" == /* && "${destination}" != / ]] \
        || die "refusing to copy the live root onto '${destination}'"

    # What this copies is whatever is running, which is only the right thing to
    # copy when what is running is Sowa. On any other host, "copy the live root"
    # would place the wrong system in the destination and could write gigabytes
    # before anything looked wrong. --from-tarball is the supported path there,
    # so name it.
    is_sowa_root / \
        || die "this is not a Sowa system, so there is no live Sowa root to copy; install from a rootfs tarball instead (--from-tarball)"

    # A Sowa root with no kernel in it is a real thing to be running inside: the
    # container image is the whole system minus /boot, because a container never
    # boots one. Copying it onto a disk is still a reasonable thing to want - a
    # chroot tree needs no kernel either - but a copy meant to boot and made
    # from here would come up to a GRUB that has nothing to load, and that is
    # worth saying before the copy rather than after the reboot.
    [[ -f /boot/vmlinuz ]] \
        || warn "this system has no /boot/vmlinuz, so the copy will not be bootable; --from-tarball installs a kernel with the tree"

    # One tar pass, skipping the kernel's virtual filesystems, the transient
    # ones, and the destination itself. Each exclude carries the "./" that tar
    # puts in front of every member name, so it names one top-level directory
    # and cannot also match a "tmp" or a "proc" further down the tree.
    #
    # Excluding the destination is what keeps the copy from feeding itself: it
    # is a path under the very tree being archived, so without this tar would
    # write into what it is still reading. A separate filesystem is no defence,
    # because tar descends into one.
    #
    # --sparse so a file that is mostly holes - /var/log/lastlog is the one
    # every system has - is written to the target as holes rather than at its
    # full apparent size, which is also how live_root_mib measured it.
    #
    # --numeric-owner on both ends because the two sides are the same system:
    # turning each uid into a name only to look the name back up is work that
    # can disagree with itself and has nothing to gain.
    #
    # --format=posix so a file written since boot keeps its sub-second mtime,
    # which the default header rounds away. It costs almost nothing here: the
    # extended header that carries the fraction is written only for a file that
    # has one, and everything from the medium came out of a squashfs, whose
    # timestamps are whole seconds already. atime and ctime are deleted from
    # that header because pax records both for every file otherwise - an extra
    # header each, for two fields extraction does not restore.
    local excludes=(
        --exclude=./proc --exclude=./sys --exclude=./dev --exclude=./run
        --exclude=./tmp --exclude=./mnt --exclude=./lost+found
        "--exclude=.${destination}"
    )
    tar -C / -cf - --sparse --numeric-owner \
        --format=posix --pax-option=delete=atime,delete=ctime \
        "${excludes[@]}" . \
        | tar -C "${destination}" -xpf - --numeric-owner
}

# The rootfs tarball form of copy_system: the same single-tar copy, but the
# source is a file the build system produced (make rootfs-tarball) rather than
# the running root, so a Sowa live system can install a pristine image instead
# of the state it happens to be in. The destination is the caller's already
# mounted directory, as with copy_system.
extract_rootfs_tarball() {
    local tarball="${1:?extract_rootfs_tarball needs a tarball}"
    local destination="${2:?extract_rootfs_tarball needs a destination}"

    [[ -f "${tarball}" ]] || die "no such file: ${tarball}"
    [[ "${destination}" == /* && "${destination}" != / ]] \
        || die "refusing to extract onto '${destination}'"

    # Refuse an archive whose members would land outside the destination, the
    # way the build system refuses a source archive before unpacking it.
    # Compression auto-detection accepts the current .tar.xz output as well as
    # a legacy .tar.gz copied from an older build.
    tar -tf "${tarball}" 2>/dev/null \
        | awk '/^\// || /(^|\/)\.\.(\/|$)/ { bad=1 } END { exit bad }' \
        || die "archive contains an unsafe path: ${tarball}"

    # --numeric-owner preserves the root ownership the tarball was written with,
    # so the result is root-owned however it was produced. -p preserves the
    # modes - the setuid ping, the 0600 shadow - that copy_system preserves too.
    tar -xpf "${tarball}" -C "${destination}" --numeric-owner
}

reset_host_keys() {
    local destination="${1:?reset_host_keys needs a destination}"
    # A host key identifies one machine. The live environment generated its own
    # at boot and copy_system just copied it across, so drop it again and let
    # sowa-sshd-keygen create a fresh key on the installed system's first boot.
    rm -f "${destination}"/etc/ssh/ssh_host_*
}

create_mount_points() {
    local destination="${1:?create_mount_points needs a destination}"
    # The directories copy_system skips still have to exist on the target: at
    # boot /etc/rc.d/rc.sysinit mounts proc, sysfs, and devtmpfs over them, and
    # mount fails on a missing mount point. sowa-chroot needs the same ones for
    # the same reason.
    local directory
    for directory in proc sys dev run tmp mnt; do
        mkdir -p "${destination}/${directory}"
    done
    chmod 1777 "${destination}/tmp"
    # The kernel opens /dev/console for init before init has mounted devtmpfs,
    # so the on-disk /dev must carry the two nodes early boot depends on.
    [[ -e "${destination}/dev/console" ]] \
        || mknod -m 0600 "${destination}/dev/console" c 5 1
    [[ -e "${destination}/dev/null" ]] \
        || mknod -m 0666 "${destination}/dev/null" c 1 3
}

set_target_hostname() {
    local destination="${1:?set_target_hostname needs a destination}" name="$2"
    printf '%s\n' "${name}" > "${destination}/etc/hostname"
}

# The column widths the generated fstab is written in. One format string for
# the header, the entries and the commented-out swap alike, so the columns line
# up whatever went in them.
readonly SOWA_FSTAB_FORMAT='%-44s %-14s %-8s %-16s %-6s %s\n'

# /etc/fstab for the system in $1, written from what is mounted underneath it.
#
# This is the one part of a layout an installer does not have to guess at. The
# boot loader's disk is a guess - which is why sowa-bootstrap prints the
# grub-install line rather than running it - but everything that belongs in an
# fstab is already mounted below the destination, so findmnt here is reading
# back the layout the operator built rather than choosing one for them.
#
# Two things it cannot read back, and both are handled by saying so rather than
# by inventing an answer:
#
#   swap        is not a mount, so nothing under the destination points at it.
#               Swap signatures found on the machine are written commented out:
#               one of them may well belong to another system sharing the disk.
#   the options  each filesystem was mounted with here belong to this
#               environment - a rescue system's own noatime, a "ro" left over
#               from a check - and not to the installed one, so every entry
#               gets "defaults" and the operator adds what they meant.
generate_fstab() {
    local destination="${1:?generate_fstab needs a destination}"
    local fstab="${destination}/etc/fstab"

    # Without a mount point there is no layout to read: findmnt --target would
    # answer for whichever filesystem merely contains the directory, which is
    # the installing system's own root. A tree destined to be a chroot needs no
    # fstab anyway.
    is_mount_point "${destination}" \
        || die "generate_fstab: ${destination} is not a mount point"
    [[ -d "${destination}/etc" ]] \
        || die "generate_fstab: ${destination}/etc does not exist"

    local entries="" root_found=0
    local line rest target source fstype uuid partuuid
    local mountpoint identifier options pass
    while IFS= read -r line; do
        # TARGET="/mnt" SOURCE="/dev/sda2" FSTYPE="ext4" UUID="..." PARTUUID="..."
        #
        # findmnt --pairs hex-escapes every character that would otherwise need
        # quoting, so no value can contain a quote of its own and each field
        # ends at the next one. That is what makes this parseable by cutting the
        # string rather than by eval, which would hand a mount point somebody
        # else created to the shell to expand.
        [[ "${line}" == TARGET=\"* ]] || continue
        rest="${line#TARGET=\"}";   target="${rest%%\"*}";   rest="${rest#*\" }"
        [[ "${rest}" == SOURCE=\"* ]] || continue
        rest="${rest#SOURCE=\"}";   source="${rest%%\"*}";   rest="${rest#*\" }"
        [[ "${rest}" == FSTYPE=\"* ]] || continue
        rest="${rest#FSTYPE=\"}";   fstype="${rest%%\"*}";   rest="${rest#*\" }"
        [[ "${rest}" == UUID=\"* ]] || continue
        rest="${rest#UUID=\"}";     uuid="${rest%%\"*}";     rest="${rest#*\" }"
        [[ "${rest}" == PARTUUID=\"* ]] || continue
        rest="${rest#PARTUUID=\"}"; partuuid="${rest%%\"*}"

        # What is not a filesystem on a block device is not a line in this file.
        # The kernel's own proc, sys and dev are mounted by rc.sysinit and not
        # from here; a tmpfs and an overlay have no device to name; and a bind
        # mount's source is not a device either, because findmnt writes it as
        # the device with the subtree in brackets ("/dev/sda2[/srv]") and the
        # test below fails on the whole string.
        [[ -b "${source}" ]] || continue
        # Both are real filesystems on real devices and neither is one an
        # installed system remounts: the squashfs is the live medium this may
        # be running from, and the iso9660 is what carries it.
        case "${fstype}" in squashfs|iso9660) continue ;; esac

        # An fstab field is whitespace-separated, so a mount point holding a
        # space has to be written with it as \040 - and findmnt handed it over
        # as \x20. Rather than translate between two escapes, leave the mount
        # out and name it, so that what is missing is missing out loud.
        if [[ "${target}" == *\\* ]]; then
            warn "leaving ${target} out of the fstab: writing that mount point needs an escape this does not translate"
            continue
        fi

        # The path the installed system will see, which is the path here with
        # the destination taken off the front. The destination itself becomes /.
        mountpoint="${target#"${destination}"}"
        mountpoint="${mountpoint:-/}"

        # UUID= first because it is the one name that fits every layout this
        # program exists for: a logical volume, an md array and a plain
        # partition all have one, and only the last has a PARTUUID. The kernel
        # command line is the other way round - it is resolved with no
        # initramfs and no udev, so GRUB's root=PARTUUID= stays as it is.
        if [[ -n "${uuid}" ]]; then
            identifier="UUID=${uuid}"
        elif [[ -n "${partuuid}" ]]; then
            identifier="PARTUUID=${partuuid}"
        else
            identifier="${source}"
            warn "${source} has no UUID, so the fstab names it by device path; that name can change between boots"
        fi

        # vfat here is the EFI System Partition in every layout this is likely
        # to meet, and noauto for the reason sowa-setup writes it noauto: a
        # machine whose ESP has been removed or reformatted still has to reach a
        # login prompt, and nothing after boot reads it.
        options=defaults
        [[ "${fstype}" == vfat ]] && options=defaults,noauto
        pass=0
        case "${fstype}" in
            ext2|ext3|ext4)
                [[ "${mountpoint}" == / ]] && pass=1 || pass=2
                ;;
            vfat)
                pass=2
                ;;
        esac
        [[ "${mountpoint}" == / ]] && root_found=1

        # Accumulated rather than written as it goes, so that a run that dies
        # part way through leaves the old file alone instead of half of a new
        # one. shellcheck disable=SC2059: the format is this file's own
        # constant, not something that came in from outside.
        # shellcheck disable=SC2059
        printf -v entries "%s${SOWA_FSTAB_FORMAT}" "${entries}" \
            "${identifier}" "${mountpoint}" "${fstype}" "${options}" 0 "${pass}"
    done < <(findmnt -R -n -P -o TARGET,SOURCE,FSTYPE,UUID,PARTUUID \
        --target "${destination}" 2>/dev/null)

    # The root entry is the one that must be there. It can go missing for a
    # layout this cannot describe - a btrfs subvolume, whose source carries the
    # subvolume in brackets and so fails the block-device test above - and a
    # boot that mounts no root is not something to find out about at the reboot.
    (( root_found == 1 )) \
        || warn "the filesystem mounted at ${destination} is not one this can name in an fstab; the file it wrote has no root entry"

    local swap="" device swap_uuid
    if command -v blkid >/dev/null 2>&1; then
        while IFS= read -r device; do
            [[ -b "${device}" ]] || continue
            # The image's own compressed swap, made at every boot by
            # /etc/rc.d/init.d/zram, which no fstab names and nothing here
            # should offer to.
            [[ "${device}" == /dev/zram* ]] && continue
            swap_uuid="$(blkid -s UUID -o value "${device}" 2>/dev/null || true)"
            [[ -n "${swap_uuid}" ]] && device="UUID=${swap_uuid}"
            # The same columns as SOWA_FSTAB_FORMAT, one narrower, so that the
            # "#" sits in front of them rather than pushing them along.
            printf -v swap "%s#%-43s %-14s %-8s %-16s %-6s %s\n" "${swap}" \
                "${device}" none swap sw 0 0
        done < <(blkid -t TYPE=swap -o device 2>/dev/null || true)
    fi

    {
        printf '# /etc/fstab - written by %s from the filesystems that\n' "${0##*/}"
        printf '# were mounted under %s at the time. Check it before\n' "${destination}"
        printf '# the first boot: whatever was not mounted then is not below.\n#\n'
        # shellcheck disable=SC2059 # the format is this file's own
        printf "${SOWA_FSTAB_FORMAT}" \
            '# <file system>' '<mount point>' '<type>' '<options>' '<dump>' '<pass>'
        printf '%s' "${entries}"
        if [[ -n "${swap}" ]]; then
            printf '\n# Swap is not a mount, so it could not be read off this layout the way\n'
            printf '# the entries above were. These are the swap areas on the machine, left\n'
            printf '# commented out because one of them may belong to another system on the\n'
            printf '# same disk. rc.sysinit runs "swapon -a" after mount -a.\n'
            printf '%s' "${swap}"
        fi
    } > "${fstab}"
    chmod 0644 "${fstab}"
}

# Ask for the installed system's root password on the terminal and print it on
# standard output, so a caller can take it through a command substitution:
#
#     password="$(prompt_root_password)"
#
# The questions go to standard error - which is where read -p writes a prompt,
# and why nothing here writes one itself - so they appear while the answer stays
# on stdout and out of the terminal.
#
# It is asked because the image ships root with an empty password. That is right
# for a live system, which is thrown away at reboot and whose sshd refuses an
# empty password outright, and wrong for one being installed: an empty answer
# here leaves a machine anyone at the console can log into as root. So an empty
# answer is allowed - a machine that will only ever be reached by key wants
# exactly that - but the caller says so out loud rather than defaulting to it
# silently.
#
# Assign it in a statement of its own. "local password=$(prompt_root_password)"
# would swallow the exit status below, because that is local's status and not
# the substitution's, and a mistyped password would carry on as an empty one.
prompt_root_password() {
    local attempt password confirmation
    for (( attempt = 0; attempt < 3; attempt++ )); do
        # IFS= so a password with a leading or trailing space is read as typed;
        # read strips both otherwise. -s so it is not echoed.
        IFS= read -r -s -p 'Root password for the installed system (empty for none): ' password
        printf '\n' >&2
        IFS= read -r -s -p 'Repeat it: ' confirmation
        printf '\n' >&2
        if [[ "${password}" == "${confirmation}" ]]; then
            printf '%s' "${password}"
            return 0
        fi
        printf 'The two do not match. Try again.\n' >&2
    done
    die "the password was mistyped three times"
}

set_target_root_password() {
    local destination="${1:?set_target_root_password needs a destination}" password="$2"
    local hash
    hash="$(printf '%s' "${password}" | openssl passwd -6 -stdin)"
    # The base system keeps password hashes in the root-only shadow database.
    [[ -f "${destination}/etc/shadow" ]] \
        || die "${destination}/etc/shadow is missing; there is no root account to set a password on"
    sed -i "s|^root:[^:]*:|root:${hash}:|" "${destination}/etc/shadow"
    # sed says nothing when it matches nothing, and a password that was asked
    # for, typed twice and then quietly not set is the one outcome here that
    # must not pass for success.
    grep -q "^root:${hash}:" "${destination}/etc/shadow" \
        || die "could not set the root password in ${destination}/etc/shadow"
}

# A Sowa root filesystem, as far as anything here can tell from the outside:
# the release file names it, and the two programs a chroot is useless without
# are present. sowa-chroot refuses a directory that fails this, because the
# alternative is bind-mounting /dev over whatever the user actually typed.
is_sowa_root() {
    local destination="${1:?is_sowa_root needs a destination}"
    [[ -r "${destination}/etc/os-release" ]] || return 1
    grep -q '^ID=sowa$' "${destination}/etc/os-release" || return 1
    [[ -x "${destination}/bin/bash" && -x "${destination}/sbin/init" ]]
}

# The partition holding $1, or nothing if it is not a mount point. Used to turn
# a destination directory back into the device name the boot loader needs.
mount_source() {
    findmnt -n -o SOURCE --target "$1" 2>/dev/null
}

is_mount_point() {
    # --mountpoint matches only when the path is itself a mount point, where
    # --target would answer with whichever filesystem merely contains it.
    findmnt -n --mountpoint "$1" >/dev/null 2>&1
}
__SOWA_INSTALL_PAYLOAD__
cat > "${directory}/sbin/sowa-setup" <<'__SOWA_INSTALL_PAYLOAD__'
#!/bin/bash
#
# sowa-setup - install Sowa from the live environment onto a disk.
#
# Booted from the ISO, Sowa's root filesystem is a read-only squashfs on the
# medium with a tmpfs overlaid on it. This script lays that root filesystem
# down onto a target disk and installs GRUB so the machine
# boots Sowa on its own, on both legacy BIOS and UEFI firmware. What is copied
# is the merged view, so anything changed since boot is copied as it is now.
#
# MBR and GPT partition tables are supported, as are ext4, XFS and Btrfs roots.
# GPT gets a BIOS Boot Partition; MBR reserves the post-MBR gap. The UEFI image
# is installed to the ESP in removable mode (EFI/BOOT/BOOTX64.EFI), so no
# firmware NVRAM entry is required. The installed system boots the on-disk
# kernel directly with root=PARTUUID=..., with no separate initramfs.
#
# Interactive by default: it asks which disk, confirms the erase, and asks for
# the root password the installed system will have. For unattended use set:
#   SOWA_SETUP_DISK           target device, e.g. /dev/sda (skips the menu)
#   SOWA_SETUP_ASSUME_YES=1   skip the destructive-wipe confirmation and the
#                             root password prompt
#   SOWA_SETUP_ROOT_PASSWORD  set the installed root password without being
#                             asked (default: ask; unattended: none)
#   SOWA_SETUP_ESP_SIZE_MB    ESP size in MiB (default 512)
#   SOWA_SETUP_HOSTNAME       hostname for the installed system
#   SOWA_SETUP_PARTITION_TABLE  mbr or gpt (default mbr)
#   SOWA_SETUP_ROOT_FILESYSTEM  ext4, xfs or btrfs (default ext4; brfs alias)
#   SOWA_SETUP_NIC_CONFIG_FILE  complete nic.conf to install, or use the
#                               guided SOWA_SETUP_NIC_* variables documented
#                               in sowa-setup(8)

set -Eeuo pipefail

TARGET_MNT=/mnt
ESP_SIZE_MB="${SOWA_SETUP_ESP_SIZE_MB:-512}"
PARTITION_TABLE="${SOWA_SETUP_PARTITION_TABLE:-}"
ROOT_FILESYSTEM="${SOWA_SETUP_ROOT_FILESYSTEM:-}"
NIC_MODE="${SOWA_SETUP_NIC_MODE:-}"
NIC_INTERFACE="${SOWA_SETUP_NIC_INTERFACE:-}"
NIC_ADDRESS="${SOWA_SETUP_NIC_ADDRESS:-}"
NIC_GATEWAY="${SOWA_SETUP_NIC_GATEWAY:-}"
NIC_DNS="${SOWA_SETUP_NIC_DNS:-}"
NIC_CONFIG_SOURCE="${SOWA_SETUP_NIC_CONFIG_FILE:-}"
GENERATED_NIC_CONFIG=""
# A directory rather than a bare file, because the generated configuration ends
# with "include nic.d/*.conf" and is validated before it is installed: nic
# resolves that relative to the file it is reading, so the check has to be run
# against the same shape the installed system will have.
GENERATED_NIC_DIR=""
# The root filesystem has to be built with -O, so this has to be e2fsprogs'
# mkfs.ext4 and nothing else. It is named by path rather than resolved through
# PATH, where an installed system may since have grown something else.
MKFS_EXT4=/usr/sbin/mkfs.ext4
MKFS_XFS=/usr/sbin/mkfs.xfs
MKFS_BTRFS=/usr/sbin/mkfs.btrfs

# Everything that writes the live root onto a mounted filesystem is shared with
# sowa-bootstrap and sowa-chroot; this script is the part that gets a disk into
# a state where that can happen, and the part that makes the result bootable.
# shellcheck source=../lib/sowa/install-functions
source "${SOWA_INSTALL_LIB:-/usr/lib/sowa}/install-functions"

cleanup() {
    umount "${TARGET_MNT}/boot/efi" 2>/dev/null || true
    umount "${TARGET_MNT}" 2>/dev/null || true
    [[ -z "${GENERATED_NIC_DIR}" ]] || rm -rf "${GENERATED_NIC_DIR}"
}
trap cleanup EXIT

# Everything the install needs whatever it is asked for, checked before the
# first question rather than after: choose_install_options validates a nic
# configuration with nic itself, so a missing nic has to be reported as a
# missing tool here and not as an invalid configuration there.
require_tools() {
    local missing=()
    mapfile -t missing < <(missing_tools sfdisk mkfs.fat grub-install blockdev \
        tar dd du lsblk mknod mktemp mount umount sync seq awk findmnt install nic)
    # openssl hashes the root password. An install that was told one cannot go
    # ahead without it; an install that would have asked for one drops the
    # question instead, which will_ask_for_password answers by looking for the
    # same program.
    if [[ -n "${SOWA_SETUP_ROOT_PASSWORD:-}" ]]; then
        command -v openssl >/dev/null 2>&1 || missing+=(openssl)
    fi
    ((${#missing[@]} == 0)) || die "missing required tools: ${missing[*]}"
}

# The one tool that cannot be checked until the root filesystem has been chosen.
# Checked by path, not by name, so the selected implementation cannot be
# shadowed by a different program earlier on PATH.
require_root_filesystem_tool() {
    case "${ROOT_FILESYSTEM}" in
        ext4) [[ -x "${MKFS_EXT4}" ]] || die "missing required tools: ${MKFS_EXT4} (e2fsprogs)" ;;
        xfs) [[ -x "${MKFS_XFS}" ]] || die "missing required tools: ${MKFS_XFS} (xfsprogs)" ;;
        btrfs) [[ -x "${MKFS_BTRFS}" ]] || die "missing required tools: ${MKFS_BTRFS} (btrfs-progs)" ;;
    esac
}

is_interactive() {
    [[ "${SOWA_SETUP_ASSUME_YES:-}" != 1 && -t 0 ]]
}

default_interface() {
    local interface=""
    if command -v ip >/dev/null 2>&1; then
        interface="$(ip -o route show default 2>/dev/null \
            | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}')"
    fi
    if [[ -z "${interface}" ]]; then
        local path
        for path in /sys/class/net/*; do
            [[ -e "${path}" ]] || continue
            interface="${path##*/}"
            [[ "${interface}" == lo ]] || break
            interface=""
        done
    fi
    printf '%s\n' "${interface:-eth0}"
}

choose_install_options() {
    local answer=""
    if [[ -z "${PARTITION_TABLE}" ]]; then
        if is_interactive; then
            printf 'Partition table [mbr/gpt] (mbr): '
            read -r answer
            PARTITION_TABLE="${answer:-mbr}"
        else
            PARTITION_TABLE=mbr
        fi
    fi
    PARTITION_TABLE="${PARTITION_TABLE,,}"
    [[ "${PARTITION_TABLE}" == dos ]] && PARTITION_TABLE=mbr
    case "${PARTITION_TABLE}" in
        mbr|gpt) ;;
        *) die "SOWA_SETUP_PARTITION_TABLE must be mbr or gpt" ;;
    esac

    if [[ -z "${ROOT_FILESYSTEM}" ]]; then
        if is_interactive; then
            printf 'Root filesystem [ext4/xfs/btrfs] (ext4): '
            read -r answer
            ROOT_FILESYSTEM="${answer:-ext4}"
        else
            ROOT_FILESYSTEM=ext4
        fi
    fi
    ROOT_FILESYSTEM="${ROOT_FILESYSTEM,,}"
    # "brfs" is accepted as a compatibility spelling for the filesystem whose
    # actual name and tools are Btrfs/btrfs-progs.
    [[ "${ROOT_FILESYSTEM}" == brfs ]] && ROOT_FILESYSTEM=btrfs
    case "${ROOT_FILESYSTEM}" in
        ext4|xfs|btrfs) ;;
        *) die "SOWA_SETUP_ROOT_FILESYSTEM must be ext4, xfs or btrfs" ;;
    esac

    if [[ -n "${NIC_CONFIG_SOURCE}" && -n "${NIC_MODE}" ]]; then
        die "set either SOWA_SETUP_NIC_CONFIG_FILE or SOWA_SETUP_NIC_MODE, not both"
    fi
    if [[ -n "${NIC_CONFIG_SOURCE}" ]]; then
        [[ -f "${NIC_CONFIG_SOURCE}" && -r "${NIC_CONFIG_SOURCE}" ]] \
            || die "cannot read NIC configuration: ${NIC_CONFIG_SOURCE}"
        nic show --config="${NIC_CONFIG_SOURCE}" >/dev/null \
            || die "invalid NIC configuration: ${NIC_CONFIG_SOURCE}"
        return
    fi

    if [[ -z "${NIC_MODE}" ]]; then
        if is_interactive; then
            printf 'NIC configuration [keep/dhcp/static/firewall-only] (keep): '
            read -r answer
            NIC_MODE="${answer:-keep}"
        else
            NIC_MODE=keep
        fi
    fi
    NIC_MODE="${NIC_MODE,,}"
    case "${NIC_MODE}" in
        none|disabled) NIC_MODE=firewall-only ;;
    esac
    case "${NIC_MODE}" in
        keep) return ;;
        dhcp|static|firewall-only) ;;
        *) die "SOWA_SETUP_NIC_MODE must be keep, dhcp, static or firewall-only" ;;
    esac

    if [[ "${NIC_MODE}" == dhcp || "${NIC_MODE}" == static ]]; then
        if [[ -z "${NIC_INTERFACE}" ]]; then
            local proposed
            proposed="$(default_interface)"
            if is_interactive; then
                printf 'Network interface (%s): ' "${proposed}"
                read -r answer
                NIC_INTERFACE="${answer:-${proposed}}"
            else
                NIC_INTERFACE="${proposed}"
            fi
        fi
        [[ "${NIC_INTERFACE}" =~ ^[[:alnum:]_.:-]+$ ]] \
            || die "SOWA_SETUP_NIC_INTERFACE contains invalid characters"
    fi

    if [[ "${NIC_MODE}" == static ]]; then
        if [[ -z "${NIC_ADDRESS}" ]] && is_interactive; then
            printf 'Static address with prefix (for example 192.0.2.10/24): '
            read -r NIC_ADDRESS
        fi
        [[ -n "${NIC_ADDRESS}" && "${NIC_ADDRESS}" != *[[:space:]]* ]] \
            || die "SOWA_SETUP_NIC_ADDRESS is required for static mode"
        if [[ -z "${NIC_GATEWAY}" ]] && is_interactive; then
            printf 'Default gateway (blank for none): '
            read -r NIC_GATEWAY
        fi
        [[ "${NIC_GATEWAY}" != *[[:space:]]* ]] \
            || die "SOWA_SETUP_NIC_GATEWAY must be one address"
        if [[ -z "${NIC_DNS}" ]] && is_interactive; then
            printf 'DNS servers, separated by spaces (blank to keep current resolvers): '
            read -r NIC_DNS
        fi
    fi

    GENERATED_NIC_DIR="$(mktemp -d)"
    GENERATED_NIC_CONFIG="${GENERATED_NIC_DIR}/nic.conf"
    mkdir -p "${GENERATED_NIC_DIR}/nic.d"
    {
        printf '# /etc/nic.conf - generated by sowa-setup\n'
        printf 'iptables /etc/nic.rules.v4\n'
        printf 'ip6tables /etc/nic.rules.v6\n'
        if [[ "${NIC_MODE}" == dhcp ]]; then
            printf 'up %s\ndhcp %s\n' "${NIC_INTERFACE}" "${NIC_INTERFACE}"
        elif [[ "${NIC_MODE}" == static ]]; then
            printf 'up %s\nip %s %s\n' \
                "${NIC_INTERFACE}" "${NIC_ADDRESS}" "${NIC_INTERFACE}"
            if [[ -n "${NIC_GATEWAY}" ]]; then
                printf 'route default via %s %s\n' "${NIC_GATEWAY}" "${NIC_INTERFACE}"
            fi
            local resolver dns_values
            local -a resolvers=()
            dns_values="${NIC_DNS//,/ }"
            read -r -a resolvers <<< "${dns_values}"
            for resolver in "${resolvers[@]}"; do
                printf 'ns %s\n' "${resolver}"
            done
        fi
        # The shipped /etc/nic.conf ends with this and /etc/nic.d exists because
        # of it. A generated configuration that left it out would take the
        # drop-in directory away from the installed system without saying so,
        # and anything put there afterwards would be ignored in silence.
        printf '\n# Include additional config files (loaded in alphanumeric / natural order)\n'
        printf 'include nic.d/*.conf\n'
    } > "${GENERATED_NIC_CONFIG}"
    chmod 0600 "${GENERATED_NIC_CONFIG}"
    nic show --config="${GENERATED_NIC_CONFIG}" >/dev/null \
        || die "the requested NIC settings do not form a valid nic configuration"
    NIC_CONFIG_SOURCE="${GENERATED_NIC_CONFIG}"
}

# Whether to ask. A password given in the environment is the answer already, an
# unattended run has nobody to ask, a stdin that is not a terminal cannot carry
# the question, and without openssl there is nothing to hash the answer with.
will_ask_for_password() {
    [[ -z "${SOWA_SETUP_ROOT_PASSWORD:-}" ]] || return 1
    [[ "${SOWA_SETUP_ASSUME_YES:-}" != 1 ]] || return 1
    [[ -t 0 ]] || return 1
    command -v openssl >/dev/null 2>&1 || return 1
}

list_disks() {
    # Whole disks only. -d stops lsblk at the top level, and the TYPE filter
    # drops what is at the top level without being a disk: an optical drive
    # (rom), a loop device, a device-mapper target. zram reports TYPE=disk
    # too, but it is RAM-backed and already in use as swap on the live
    # system, so it is excluded by name rather than by type.
    lsblk -d -n -o NAME,SIZE,TYPE 2>/dev/null | awk '$3 == "disk" && $1 !~ /^zram[0-9]*$/'
}

is_whole_disk() {
    # The same question list_disks answers for every device, asked about one:
    # a partition reports "part" here, and the caller has already established
    # that the name is a block device that exists.
    [[ "$(lsblk -d -n -o TYPE "$1" 2>/dev/null)" == disk ]]
}

choose_disk() {
    if [[ -n "${SOWA_SETUP_DISK:-}" ]]; then
        DISK="${SOWA_SETUP_DISK}"
        return
    fi
    printf 'Available disks:\n'
    list_disks | awk '{printf "  /dev/%s\t%s\n", $1, $2}'
    printf 'Target disk (e.g. /dev/sda): '
    read -r DISK
}

partition_suffix() {
    # nvme/mmc devices insert a "p" before the partition number.
    [[ "$1" =~ [0-9]$ ]] && printf 'p' || printf ''
}

confirm_wipe() {
    if [[ "${SOWA_SETUP_ASSUME_YES:-}" == 1 ]]; then
        return
    fi
    printf '\nThis will ERASE ALL DATA on %s and install Sowa.\n' "${DISK}"
    printf 'Type "yes" to continue: '
    local answer
    read -r answer
    [[ "${answer}" == yes ]] || die "aborted at user request"
}

main() {
    require_root
    require_supported_host_kernel
    require_tools
    choose_disk
    choose_install_options
    require_root_filesystem_tool

    [[ -b "${DISK}" ]] || die "not a block device: ${DISK}"
    is_whole_disk "${DISK}" || die "${DISK} is not a whole disk; pass the disk, not a partition"
    # Not in list_disks, but reachable via SOWA_SETUP_DISK or a hand-typed
    # answer: zram is RAM-backed and already carrying the live system's swap.
    [[ "${DISK}" =~ ^/dev/zram[0-9]*$ ]] \
        && die "${DISK} is zram, a RAM-backed device with nothing to install onto"
    if grep -q "^${DISK}" /proc/mounts 2>/dev/null; then
        die "${DISK} has mounted partitions; unmount them first"
    fi
    [[ -f /boot/vmlinuz ]] || die "/boot/vmlinuz is missing from the live system"
    [[ "${ESP_SIZE_MB}" =~ ^[0-9]+$ ]] \
        || die "SOWA_SETUP_ESP_SIZE_MB must be a whole number of MiB"
    # mkfs.fat -F32 needs 65525 clusters, which no smaller ESP can provide.
    [[ "${ESP_SIZE_MB}" -ge 34 ]] || die "the ESP must be at least 34 MiB"

    confirm_wipe

    # Asked here, before the first write, and applied after the copy: the copy
    # takes minutes and there is nobody to answer a question at the end of them.
    # The declaration and the assignment are separate on purpose - see
    # prompt_root_password, whose exit status "local password=$(...)" would eat.
    local root_password="${SOWA_SETUP_ROOT_PASSWORD:-}"
    if will_ask_for_password; then
        printf '\n'
        root_password="$(prompt_root_password)"
        [[ -n "${root_password}" ]] \
            || warn "no root password set; the installed system's console will let root in without one"
    elif [[ -z "${root_password}" ]]; then
        warn "no root password set; set one with passwd after the first boot"
    fi

    local suffix esp root esp_number root_number
    suffix="$(partition_suffix "${DISK}")"
    if [[ "${PARTITION_TABLE}" == gpt ]]; then
        esp_number=2
        root_number=3
    else
        esp_number=1
        root_number=2
    fi
    esp="${DISK}${suffix}${esp_number}"
    root="${DISK}${suffix}${root_number}"

    local total_sectors esp_start esp_size root_start root_size total_mib tail_size
    total_sectors="$(blockdev --getsz "${DISK}")"
    esp_size=$(( ESP_SIZE_MB * 2048 ))
    tail_size=0
    if [[ "${PARTITION_TABLE}" == gpt ]]; then
        # Sector 2048 begins a 1 MiB BIOS Boot Partition. The ESP starts on the
        # next MiB boundary, and another MiB is left for GPT's backup header and
        # alignment at the end of the disk.
        esp_start=4096
        tail_size=2048
    else
        # The first MiB is the embedding area GRUB's i386-pc core uses.
        esp_start=2048
    fi
    root_start=$(( esp_start + esp_size ))
    root_size=$(( total_sectors - root_start - tail_size ))

    # The whole live root is copied to the target, so size the check against
    # what it actually occupies plus room for filesystem metadata and operating
    # headroom; otherwise the copy fails part-way through a disk that passed a
    # fixed minimum.
    local live_mib root_mib needed_mib
    live_mib="$(live_root_mib)"
    needed_mib=$(( live_mib + live_mib / 2 + 64 ))
    root_mib=$(( root_size / 2048 ))
    [[ "${root_mib}" -ge "${needed_mib}" ]] \
        || die "disk too small; the root partition needs ${needed_mib} MiB for a ${live_mib} MiB system, but only ${root_mib} MiB is left after a ${ESP_SIZE_MB} MiB ESP"

    log "wiping existing signatures on ${DISK}"
    dd if=/dev/zero of="${DISK}" bs=1M count=1 status=none
    total_mib=$(( total_sectors / 2048 ))
    if [[ "${total_mib}" -gt 1 ]]; then
        dd if=/dev/zero of="${DISK}" bs=1M seek=$(( total_mib - 1 )) count=1 \
            status=none 2>/dev/null || true
    fi
    sync

    log "partitioning ${DISK} (${PARTITION_TABLE}, ESP ${ESP_SIZE_MB} MiB + ${ROOT_FILESYSTEM} root)"
    if [[ "${PARTITION_TABLE}" == gpt ]]; then
        printf 'label: gpt\n\nstart=2048, size=2048, type=21686148-6449-6E6F-744E-656564454649, name="BIOS boot"\nstart=%s, size=%s, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name="EFI System"\nstart=%s, size=%s, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name="Sowa root"\n' \
            "${esp_start}" "${esp_size}" "${root_start}" "${root_size}" \
            | sfdisk --force "${DISK}"
    else
        printf 'label: dos\n\nstart=%s, size=%s, type=ef\nstart=%s, size=%s, type=83\n' \
            "${esp_start}" "${esp_size}" "${root_start}" "${root_size}" \
            | sfdisk --force "${DISK}"

        # Give every MBR install a stable, non-zero identity. PARTUUID is this
        # four-byte signature plus the partition number.
        log "writing a random MBR disk signature"
        local sig_file
        sig_file="$(mktemp)"
        dd if=/dev/urandom of="${sig_file}" bs=4 count=1 status=none
        dd if="${sig_file}" of="${DISK}" bs=1 seek=440 count=4 conv=notrunc status=none
        rm -f "${sig_file}"
    fi
    sync

    blockdev --rereadpt "${DISK}"
    for _ in $(seq 1 10); do
        [[ -b "${esp}" && -b "${root}" ]] && break
        sleep 1
    done
    [[ -b "${esp}" && -b "${root}" ]] \
        || die "partition device nodes did not appear (${esp}, ${root})"

    # Read identifiers back from the table for both formats. GPT uses partition
    # GUIDs; DOS uses the disk signature written above and a partition suffix.
    local root_partuuid="" esp_partuuid=""
    for _ in $(seq 1 10); do
        root_partuuid="$(lsblk -n -o PARTUUID "${root}" 2>/dev/null \
            | awk 'NF {print $1; exit}')"
        esp_partuuid="$(lsblk -n -o PARTUUID "${esp}" 2>/dev/null \
            | awk 'NF {print $1; exit}')"
        [[ -n "${root_partuuid}" && -n "${esp_partuuid}" ]] && break
        sleep 1
    done
    [[ -n "${root_partuuid}" && -n "${esp_partuuid}" ]] \
        || die "could not read PARTUUIDs for ${esp} and ${root}"

    log "creating filesystems"
    mkfs.fat -F32 -n SOWA_ESP "${esp}" >/dev/null
    case "${ROOT_FILESYSTEM}" in
        ext4)
            # These features are disabled so GRUB can read /boot directly.
            "${MKFS_EXT4}" -F -O '^orphan_file,^metadata_csum_seed' \
                -L sowa-root "${root}" >/dev/null
            ;;
        xfs)
            "${MKFS_XFS}" -f -L sowa-root "${root}" >/dev/null
            ;;
        btrfs)
            "${MKFS_BTRFS}" -f -L sowa-root "${root}" >/dev/null
            ;;
    esac

    log "copying the system to ${root} (this can take a while)"
    mkdir -p "${TARGET_MNT}"
    mount -t "${ROOT_FILESYSTEM}" "${root}" "${TARGET_MNT}"
    copy_system "${TARGET_MNT}"
    reset_host_keys "${TARGET_MNT}"
    create_mount_points "${TARGET_MNT}"

    # Mount the ESP only after the copy so it does not shadow /mnt/boot.
    mkdir -p "${TARGET_MNT}/boot/efi"
    mount -t vfat "${esp}" "${TARGET_MNT}/boot/efi"

    log "writing /etc/fstab"
    local root_pass=0
    [[ "${ROOT_FILESYSTEM}" == ext4 ]] && root_pass=1
    # printf rather than a heredoc: the type column holds "ext4", "xfs" or
    # "btrfs", and a heredoc can only line the header up with one of them. The
    # widths are install-functions' constant, so this file and the one
    # sowa-bootstrap generates are laid out identically.
    # shellcheck disable=SC2059 # the format is that constant, not outside input
    {
        printf "${SOWA_FSTAB_FORMAT}" \
            '# <file system>' '<mount point>' '<type>' '<options>' '<dump>' '<pass>'
        printf "${SOWA_FSTAB_FORMAT}" \
            "PARTUUID=${root_partuuid}" / "${ROOT_FILESYSTEM}" defaults 0 "${root_pass}"
        printf "${SOWA_FSTAB_FORMAT}" \
            "PARTUUID=${esp_partuuid}" /boot/efi vfat defaults,noauto 0 2
    } > "${TARGET_MNT}/etc/fstab"

    if [[ -n "${NIC_CONFIG_SOURCE}" ]]; then
        log "installing the requested NIC configuration"
        install -m 0600 "${NIC_CONFIG_SOURCE}" "${TARGET_MNT}/etc/nic.conf"
    fi

    if [[ -n "${SOWA_SETUP_HOSTNAME:-}" ]]; then
        set_target_hostname "${TARGET_MNT}" "${SOWA_SETUP_HOSTNAME}"
    fi

    if [[ -n "${root_password}" ]]; then
        log "setting the root password"
        set_target_root_password "${TARGET_MNT}" "${root_password}"
    fi

    log "installing GRUB for BIOS (i386-pc)"
    grub-install --target=i386-pc --boot-directory="${TARGET_MNT}/boot" \
        --recheck "${DISK}"

    log "installing GRUB for UEFI (x86_64-efi, removable)"
    grub-install --target=x86_64-efi --efi-directory="${TARGET_MNT}/boot/efi" \
        --boot-directory="${TARGET_MNT}/boot" --removable --no-nvram --recheck

    log "writing the boot menu"
    # One entry, naming both consoles. There used to be a second that differed
    # only in the order of the "console=" arguments, because the order decides
    # /dev/console and /dev/console was the only terminal with a login prompt.
    # The inittab runs a getty per console now, so the screen and the serial
    # line each have one whichever way round these are written.
    #
    # The serial probe stays, guarded: it decides where GRUB draws its own menu,
    # and naming a terminal GRUB never registered - which is what "serial" fails
    # to do on a machine with no UART - is an error.
    cat > "${TARGET_MNT}/boot/grub/grub.cfg" <<EOF
set default=0
set timeout=5

if serial --unit=0 --speed=115200; then
    terminal_input serial console
    terminal_output serial console
fi

menuentry "Sowa Linux" {
    linux /boot/vmlinuz root=PARTUUID=${root_partuuid} rw console=tty0 console=ttyS0,115200 init=/sbin/init panic=-1
}
EOF

    sync
    umount "${TARGET_MNT}/boot/efi"
    umount "${TARGET_MNT}"
    sync
    # Everything the trap would have done, done here, because clearing the trap
    # is what stops it running on the way out of a successful install.
    cleanup
    trap - EXIT

    log "Sowa is installed on ${DISK}."
    log "Remove the installation media and reboot."
}

main "$@"
__SOWA_INSTALL_PAYLOAD__
cat > "${directory}/sbin/sowa-bootstrap" <<'__SOWA_INSTALL_PAYLOAD__'
#!/bin/bash
#
# sowa-bootstrap - install Sowa into a filesystem someone else has mounted.
#
# This is sowa-setup with the disk half removed. sowa-setup owns the whole
# device: it chooses one of its whole-disk layouts, formats, installs GRUB and
# writes a boot menu. sowa-bootstrap takes a directory instead and copies the
# live root into it, leaving the
# partitioning, the filesystems and the boot loader to whoever ran it - which is
# the way to get Sowa onto LVM, onto RAID, onto an encrypted root, into a custom
# multiboot layout, or into a plain directory to be used as a chroot.
#
# The one part of the layout it does write is /etc/fstab, because that part is
# not a guess: whatever belongs in the file is already mounted under the
# destination, so it is read back rather than chosen. --no-fstab declines.
#
# It ends by printing what is left to do, filled in with the real device names
# where it can work them out, because the part it does not do is the part that
# decides whether the machine boots.
#
#   sowa-bootstrap /mnt
#   sowa-bootstrap --from-tarball sowa-0.1-x86_64-rootfs.tar.xz /mnt
#
# With --from-tarball the source is a rootfs tarball made by 'make
# rootfs-tarball' (or fetched from a mirror) instead of the running system, so
# the copy installs that image as it was built rather than the live root as it
# now stands. Everything else is the same: the copy, the host-key reset, and
# the three steps left for whoever made the layout.
#
# Interactive by default: it confirms the destination and asks for the root
# password the installed system will have. For unattended use set:
#   SOWA_BOOTSTRAP_ASSUME_YES=1     skip the confirmation and the root password
#                                   prompt
#   SOWA_BOOTSTRAP_ROOT_PASSWORD    set the installed root password without
#                                   being asked (default: ask; unattended: none)
#   SOWA_BOOTSTRAP_HOSTNAME         hostname for the installed system
#   SOWA_BOOTSTRAP_QUIET=1          do not print the closing instructions

set -Eeuo pipefail

# shellcheck source=../lib/sowa/install-functions
source "${SOWA_INSTALL_LIB:-/usr/lib/sowa}/install-functions"

usage() {
    printf 'usage: %s [--from-tarball TARBALL] [--no-fstab] DESTINATION\n\n' "${0##*/}"
    printf 'Copy the running Sowa system into DESTINATION, which must already\n'
    printf 'be mounted (or be an ordinary directory, for a chroot tree).\n'
    printf '\n--from-tarball TARBALL\n'
    printf '    install from a rootfs tarball (make rootfs-tarball) instead of\n'
    printf '    copying the running system.\n'
    printf '\n--no-fstab\n'
    printf '    do not write DESTINATION/etc/fstab from the mounts under it.\n'
    exit "${1:-1}"
}

require_tools() {
    local tarball="${1:-}"
    local missing=()
    mapfile -t missing < <(missing_tools tar du df awk sed findmnt lsblk mknod mkdir)
    # GNU tar invokes the matching external decompressor. A live-root copy is
    # an uncompressed pipe and needs neither one. New release tarballs use XZ,
    # while the extractor still accepts gzip artifacts made by an older build.
    case "${tarball}" in
        *.xz | *.txz)
            command -v xz >/dev/null 2>&1 || missing+=(xz)
            ;;
        *.gz | *.tgz)
            command -v gzip >/dev/null 2>&1 || missing+=(gzip)
            ;;
    esac
    # blkid is not in that list on purpose. It is wanted for one thing - the
    # swap areas the generated fstab lists commented out - and swap is the part
    # of the file the operator has to decide about anyway, so a rescue system
    # without blkid gets an fstab with no swap section rather than no install.
    # openssl hashes the root password. Told one, this cannot go ahead without
    # it; left to ask for one, it drops the question instead - which matters
    # here more than in sowa-setup, because this program is the one that runs on
    # somebody else's rescue system, where openssl may genuinely be absent.
    if [[ -n "${SOWA_BOOTSTRAP_ROOT_PASSWORD:-}" ]]; then
        command -v openssl >/dev/null 2>&1 || missing+=(openssl)
    fi
    ((${#missing[@]} == 0)) || die "missing required tools: ${missing[*]}"
}

# Whether to ask. A password given in the environment is the answer already, an
# unattended run has nobody to ask, a stdin that is not a terminal cannot carry
# the question, and without openssl there is nothing to hash the answer with.
will_ask_for_password() {
    [[ -z "${SOWA_BOOTSTRAP_ROOT_PASSWORD:-}" ]] || return 1
    [[ "${SOWA_BOOTSTRAP_ASSUME_YES:-}" != 1 ]] || return 1
    [[ -t 0 ]] || return 1
    command -v openssl >/dev/null 2>&1 || return 1
}

# What is already in the destination, ignoring the conventional lost+found
# directory some filesystem creators leave. A non-empty destination is not refused -
# installing beside an existing system is one of the reasons this program exists
# - but it is what the confirmation is for.
destination_entries() {
    local destination="$1" entry count=0
    for entry in "${destination}"/* "${destination}"/.[!.]*; do
        [[ -e "${entry}" || -L "${entry}" ]] || continue
        [[ "${entry##*/}" == "lost+found" ]] && continue
        count=$(( count + 1 ))
    done
    printf '%s\n' "${count}"
}

check_space() {
    local destination="$1" live_mib="${2:-}" available_mib needed_mib
    if [[ -z "${live_mib}" ]]; then
        live_mib="$(live_root_mib)"
    fi
    # df answers for the filesystem holding the destination, which is the one
    # about to be written to. Unlike sowa-setup, there is no filesystem to make
    # here, so what is left after mkfs is already accounted for and only a
    # little room on top of the tree itself is wanted.
    # "|| true" because a df that cannot answer must leave the check to be
    # skipped, not kill the program: the assignment itself is what set -e would
    # otherwise trip on, before the test below gets to decide anything.
    available_mib="$(df -Pk "${destination}" 2>/dev/null | awk 'NR == 2 { print int($4 / 1024) }' || true)"
    needed_mib=$(( live_mib + live_mib / 20 ))
    [[ "${available_mib}" =~ ^[0-9]+$ ]] || return 0
    [[ "${available_mib}" -ge "${needed_mib}" ]] \
        || die "not enough room on ${destination}: the live system is ${live_mib} MiB and wants ${needed_mib} MiB, but only ${available_mib} MiB is free"
}

confirm() {
    local destination="$1" entries="$2" tarball="${3:-}"
    if [[ "${SOWA_BOOTSTRAP_ASSUME_YES:-}" == 1 ]]; then
        return
    fi
    if [[ -n "${tarball}" ]]; then
        printf '\nThis will install Sowa from %s into %s.\n' "${tarball}" "${destination}"
    else
        printf '\nThis will copy the running Sowa system into %s.\n' "${destination}"
    fi
    if [[ "${entries}" -gt 0 ]]; then
        printf 'That directory is NOT empty (%s entries): files with the same\n' "${entries}"
        printf 'names as the ones being copied will be OVERWRITTEN.\n'
    fi
    printf 'Type "yes" to continue: '
    local answer
    read -r answer
    [[ "${answer}" == yes ]] || die "aborted at user request"
}

# What is left to do, with the device names filled in wherever they can be
# established. Everything below is deliberately printed rather than done: the
# layout is the caller's, and a boot loader written to a disk this program only
# guessed at is the one mistake it must not make.
next_steps() {
    local destination="$1" fstab_written="${2:-0}"
    local root_device root_uuid root_partuuid root_fstype root_pass disk

    # How to name sowa-chroot in the instructions below. Run from the image it
    # is a command on PATH and this is just its name; run from the bundled
    # sowa-install it is not, because the bundle unpacked it into a temporary
    # directory that is deleted before anyone reads this. The bundle passes in
    # the name that will still work.
    local chroot_command="${SOWA_INSTALL_CHROOT_COMMAND:-sowa-chroot}"

    # Every one of these is allowed to come back empty - a destination that is
    # not a mount point has no device behind it, and a whole-disk filesystem has
    # no PARTUUID and no parent - so each ends in "|| true". Without it the
    # failing command substitution, not the empty answer, is what would be
    # reported.
    root_device="$(mount_source "${destination}" || true)"
    if [[ -n "${root_device}" ]]; then
        root_uuid="$(lsblk -d -n -o UUID "${root_device}" 2>/dev/null || true)"
        root_partuuid="$(lsblk -d -n -o PARTUUID "${root_device}" 2>/dev/null || true)"
        root_fstype="$(lsblk -d -n -o FSTYPE "${root_device}" 2>/dev/null || true)"
        disk="$(lsblk -n -o PKNAME "${root_device}" 2>/dev/null | awk 'NF { print; exit }' || true)"
        [[ -n "${disk}" ]] && disk="/dev/${disk}"
    fi

    printf '\n'
    if (( fstab_written == 1 )); then
        log "Sowa is installed in ${destination}. Two things are left, and this"
        log "program does neither, because they depend on a layout only you know."
        log "The one file it did write is the first thing below, to be read over."
    else
        log "Sowa is installed in ${destination}. Three things are left, and this"
        log "program does none of them, because they depend on a layout only you know."
    fi
    printf '\n'

    if (( fstab_written == 1 )); then
        printf '1. /etc/fstab, written from the filesystems you had mounted under %s:\n\n' \
            "${destination}"
        # Indented so the file is clearly the file and not more prose. sed
        # rather than a read loop: a line here can hold a backslash, which read
        # would eat and printf %s would not put back.
        sed 's/^./     &/' "${destination}/etc/fstab"
        printf '\n   Read it before the first boot. Anything that was not mounted when\n'
        printf '   this ran is not in it, and swap never is - swap is not a mount, so\n'
        printf '   what was found on the machine is listed commented out, for you to\n'
        printf '   turn on once you know it is yours.\n'
    else
        printf '1. /etc/fstab. Nothing wrote one - the image ships none - and a system\n'
        printf '   that boots without one mounts nothing beyond the root the kernel was\n'
        printf '   handed on its command line.\n'
        if [[ -n "${root_uuid:-}" ]]; then
            printf '   The root filesystem you mounted is %s, UUID=%s:\n\n' \
                "${root_device}" "${root_uuid}"
            root_pass=0
            case "${root_fstype:-}" in ext2|ext3|ext4) root_pass=1 ;; esac
            printf '     UUID=%s  /  %s  defaults  0  %s\n' \
                "${root_uuid}" "${root_fstype:-<type>}" "${root_pass}"
            printf '\n   Add a line for the EFI System Partition too if this machine boots UEFI:\n\n'
            printf '     UUID=<esp>  /boot/efi  vfat  defaults,noauto  0  2\n'
        elif [[ -n "${root_device:-}" ]]; then
            printf '   The filesystem you mounted is %s. Name it by UUID or PARTUUID\n' "${root_device}"
            printf '   rather than by /dev/sdXN, which is not stable across boots:\n\n'
            printf '     lsblk -n -o NAME,UUID,PARTUUID %s\n' "${root_device}"
        else
            printf '   %s is not a mount point, so there is no device to name in one.\n' "${destination}"
            printf '   If this tree is going to be a chroot rather than a bootable system,\n'
            printf '   that is fine and steps 2 and 3 do not apply.\n'
        fi
    fi

    # Both of the remaining steps are given as sowa-chroot commands, and the
    # paths in them are the installed system's own rather than this one's.
    # Running them from out here would work on a machine whose GRUB is as new as
    # Sowa's, and quietly not on one whose GRUB is older - a rescue environment
    # shipping GRUB 0.97 is the case that made this worth changing. Inside the
    # chroot it is Sowa's own grub-install and grub-mkconfig either way, so the
    # commands below are the same on every host.
    printf '\n2. GRUB. Run both if the machine should boot either firmware:\n\n'
    printf '     %s %s grub-install --target=i386-pc %s\n' \
        "${chroot_command}" "${destination}" "${disk:-<disk>}"
    printf '     %s %s grub-install --target=x86_64-efi \\\n' "${chroot_command}" "${destination}"
    printf '                 --efi-directory=/boot/efi --removable --no-nvram\n'
    printf '\n   The UEFI line needs the ESP mounted at %s/boot/efi first.\n' "${destination}"
    printf '   --removable writes EFI/BOOT/BOOTX64.EFI, which firmware boots with\n'
    printf '   no NVRAM entry, so no efibootmgr is needed.\n'

    printf '\n3. A boot menu at %s/boot/grub/grub.cfg. Sowa boots its kernel\n' "${destination}"
    printf '   directly, with no initramfs. grub-mkconfig can write it from the\n'
    printf '   system'\''s own settings - after step 2, which is what creates the\n'
    printf '   /boot/grub the file goes in:\n\n'
    printf '     %s %s grub-mkconfig -o /boot/grub/grub.cfg\n' "${chroot_command}" "${destination}"
    printf '\n   Or write it by hand:\n\n'
    printf '     set default=0\n'
    printf '     set timeout=5\n'
    printf '     menuentry "Sowa Linux" {\n'
    printf '         linux /boot/vmlinuz root=PARTUUID=%s rw console=tty0 console=ttyS0,115200 init=/sbin/init panic=-1\n' \
        "${root_partuuid:-<root-partuuid>}"
    printf '     }\n'

    printf '\nTo do any of this from inside the installed system rather than from here:\n\n'
    printf '     %s %s\n\n' "${chroot_command}" "${destination}"
}

main() {
    local tarball="" write_fstab=1
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help) usage 0 ;;
            --from-tarball)
                [[ $# -ge 2 ]] || usage
                tarball="$2"
                shift 2 ;;
            --from-tarball=*)
                tarball="${1#*=}"
                shift ;;
            --no-fstab)
                write_fstab=0
                shift ;;
            -*) usage ;;
            *) break ;;
        esac
    done
    [[ $# -eq 1 ]] || usage
    case "$1" in
        -h|--help) usage 0 ;;
        -*) usage ;;
    esac

    require_root
    require_supported_host_kernel
    require_tools "${tarball}"

    [[ -d "$1" ]] || die "not a directory: $1"
    local destination
    destination="$(normalize_path "$1")" || die "cannot resolve: $1"
    [[ "${destination}" != / ]] || die "refusing to bootstrap onto the running root filesystem"
    [[ -w "${destination}" ]] || die "not writable: ${destination}"

    if [[ -n "${tarball}" ]]; then
        [[ -f "${tarball}" ]] || die "no such file: ${tarball}"
    else
        # Without a tarball the source is the running system, which is only the
        # right source when the running system is Sowa. copy_system refuses this
        # too, but it refuses it several minutes too late: live_root_mib runs
        # "du -sx /" first, so on a non-Sowa host the program would walk that
        # system's entire root - potentially hundreds of gigabytes - to size a
        # copy it was never going to make.
        is_sowa_root / \
            || die "this is not a Sowa system, so there is no live root to copy; use --from-tarball TARBALL to install from a rootfs tarball"
    fi

    if ! is_mount_point "${destination}"; then
        warn "${destination} is not a mount point; the copy will land on the filesystem that already holds it"
    fi

    local source_mib=""
    [[ -n "${tarball}" ]] && source_mib="$(tarball_root_mib "${tarball}")"

    check_space "${destination}" "${source_mib}"
    confirm "${destination}" "$(destination_entries "${destination}")" "${tarball}"

    # Asked before the copy and applied after it: the copy takes minutes, and a
    # question waiting at the end of them is a question nobody is there for. The
    # declaration and the assignment are separate on purpose - see
    # prompt_root_password, whose exit status "local password=$(...)" would eat.
    local root_password="${SOWA_BOOTSTRAP_ROOT_PASSWORD:-}"
    if will_ask_for_password; then
        printf '\n'
        root_password="$(prompt_root_password)"
        [[ -n "${root_password}" ]] \
            || warn "no root password set; the installed system's console will let root in without one"
    elif [[ -z "${root_password}" ]]; then
        warn "no root password set; set one with '${SOWA_INSTALL_CHROOT_COMMAND:-sowa-chroot} ${destination} passwd' or after the first boot"
    fi

    if [[ -n "${tarball}" ]]; then
        log "extracting ${tarball} into ${destination} (this can take a while)"
        extract_rootfs_tarball "${tarball}" "${destination}"
        is_sowa_root "${destination}" \
            || die "${tarball} does not contain a Sowa system"
    else
        log "copying the system into ${destination} (this can take a while)"
        copy_system "${destination}"
    fi
    reset_host_keys "${destination}"
    create_mount_points "${destination}"

    # Written over whatever the copy brought, which is the point: an fstab that
    # came from a running installation names that machine's disks, and one that
    # came from the image or a tarball does not exist at all - the live system
    # ships none, because its root is a squashfs the kernel command line found.
    # Either way the file describing this layout is the one to have, and
    # --no-fstab is there for the operator who has already written it.
    local fstab_written=0
    if (( write_fstab == 1 )) && is_mount_point "${destination}"; then
        log "writing /etc/fstab from the filesystems mounted under ${destination}"
        generate_fstab "${destination}"
        fstab_written=1
    fi

    if [[ -n "${SOWA_BOOTSTRAP_HOSTNAME:-}" ]]; then
        set_target_hostname "${destination}" "${SOWA_BOOTSTRAP_HOSTNAME}"
    fi
    if [[ -n "${root_password}" ]]; then
        log "setting the root password"
        set_target_root_password "${destination}" "${root_password}"
    fi

    sync
    [[ "${SOWA_BOOTSTRAP_QUIET:-}" == 1 ]] || next_steps "${destination}" "${fstab_written}"
}

main "$@"
__SOWA_INSTALL_PAYLOAD__
cat > "${directory}/sbin/sowa-chroot" <<'__SOWA_INSTALL_PAYLOAD__'
#!/bin/bash
#
# sowa-chroot - enter an installed Sowa system with its kernel filesystems in place.
#
# chroot(8) alone changes the root directory and nothing else, which leaves the
# new root without /proc, /sys or /dev. Almost everything worth running in there
# needs at least one of them: grub-install reads /sys and /dev to find the disk
# it is being pointed at, sowa-pkg wants /dev/urandom, and ps and mount read
# /proc. This mounts the four of them, runs a shell (or the command given after
# the destination), and unmounts them again on the way out - in reverse, and
# whether the shell exited cleanly or not.
#
#   sowa-chroot /mnt                 an interactive login shell
#   sowa-chroot /mnt grub-install ... one command, then leave
#
# The destination has to hold a Sowa system - what sowa-setup or sowa-bootstrap
# leaves behind - because the alternative is bind-mounting /dev over a directory
# that was a typo. It does not have to be a mount point: a tree bootstrapped
# into a plain directory is one of the things sowa-bootstrap is for.

set -Eeuo pipefail

# shellcheck source=../lib/sowa/install-functions
source "${SOWA_INSTALL_LIB:-/usr/lib/sowa}/install-functions"

# The mounts made so far, innermost last, so unwinding is this list backwards.
# It is filled in as each mount succeeds rather than listed up front, so a
# failure part way through unmounts what it made and nothing else.
mounted=()

usage() {
    printf 'usage: %s DESTINATION [COMMAND [ARGUMENT...]]\n\n' "${0##*/}"
    printf 'Enter the Sowa system mounted at DESTINATION with /proc, /sys, /dev\n'
    printf 'and /run mounted inside it. Without a COMMAND, runs a login shell.\n'
    exit "${1:-1}"
}

require_tools() {
    local missing=()
    mapfile -t missing < <(missing_tools chroot mount umount findmnt)
    ((${#missing[@]} == 0)) || die "missing required tools: ${missing[*]}"
}

unmount_all() {
    # Reverse order: an outer filesystem cannot be unmounted while an inner one
    # is still on top of it. -R for /dev, which carries devpts and whatever else
    # the running system has under it.
    local index
    for (( index = ${#mounted[@]} - 1; index >= 0; index-- )); do
        local point="${mounted[index]}"
        umount -R "${point}" 2>/dev/null && continue
        # Something in the chroot is still holding it - a daemon the user
        # started, most often. A lazy unmount detaches it now and lets the
        # kernel finish when the last user lets go, which is better than
        # leaving the host's /dev bind-mounted under a directory forever.
        umount -R -l "${point}" 2>/dev/null \
            || warn "could not unmount ${point}"
    done
    mounted=()
}
trap unmount_all EXIT

mount_kernel_filesystems() {
    local destination="$1"

    # proc and sysfs are mounted fresh rather than bound, because they are the
    # same filesystem wherever they appear and a second mount of one costs
    # nothing. /dev is bound instead: a fresh devtmpfs would carry the device
    # nodes but not /dev/pts, which is a separate filesystem mounted over it,
    # and without a pty there is no job control in the chroot's shell.
    # --make-rslave is what keeps the recursive unmount from propagating back
    # out and unmounting the host's /dev/pts along with the copy.
    mkdir -p "${destination}"/{proc,sys,dev,run}

    mount -t proc proc "${destination}/proc" || die "cannot mount proc on ${destination}/proc"
    mounted+=("${destination}/proc")

    mount -t sysfs sys "${destination}/sys" || die "cannot mount sysfs on ${destination}/sys"
    mounted+=("${destination}/sys")

    mount --rbind /dev "${destination}/dev" || die "cannot bind /dev onto ${destination}/dev"
    mounted+=("${destination}/dev")
    mount --make-rslave "${destination}/dev" \
        || warn "${destination}/dev is not a slave mount; unmounting it may affect the host's /dev"

    # A tmpfs rather than a bind of the host's /run: /run holds the state of the
    # processes that are running *now*, and the chroot's are not those. This is
    # also what the installed system gets from rc.sysinit at boot, so anything
    # run in here sees the /run it would see for real.
    mount -t tmpfs run "${destination}/run" || die "cannot mount tmpfs on ${destination}/run"
    mounted+=("${destination}/run")

    # Only when the host booted UEFI, and only then because grub-install writes
    # a boot entry through it. On a BIOS machine the directory does not exist
    # and nothing in the chroot will look for it.
    if [[ -d /sys/firmware/efi/efivars ]] && findmnt -n --mountpoint /sys/firmware/efi/efivars >/dev/null 2>&1; then
        if mount -t efivarfs efivarfs "${destination}/sys/firmware/efi/efivars" 2>/dev/null; then
            mounted+=("${destination}/sys/firmware/efi/efivars")
        else
            warn "could not mount efivarfs; grub-install will not be able to write an NVRAM boot entry"
        fi
    fi
}

main() {
    [[ $# -ge 1 ]] || usage
    case "$1" in
        -h|--help) usage 0 ;;
        -*) usage ;;
    esac

    require_root
    require_supported_host_kernel
    require_tools

    [[ -d "$1" ]] || die "not a directory: $1"
    local destination
    destination="$(normalize_path "$1")" || die "cannot resolve: $1"
    shift

    [[ "${destination}" != / ]] || die "refusing to chroot into the running root filesystem"
    # Holding a Sowa system is the requirement, not being a mount point. Being a
    # mount point was the first form of this check and it was the wrong one: it
    # refused exactly the case sowa-bootstrap advertises and its own closing
    # instructions suggest - a plain directory bootstrapped as a chroot tree -
    # while doing nothing the check below does not already do better. A typo
    # does not have a Sowa system in it; that is what makes it safe to bind /dev
    # onto this path.
    is_sowa_root "${destination}" \
        || die "${destination} does not hold a Sowa system; run sowa-bootstrap ${destination} first"
    is_mount_point "${destination}" \
        || warn "${destination} is not a mount point; entering it as a plain directory tree"

    mount_kernel_filesystems "${destination}"

    log "entering ${destination} (exit or Ctrl-D to leave)"
    local status=0
    if [[ $# -eq 0 ]]; then
        # -l so the shell reads /etc/profile and comes up as a login shell,
        # which is what makes PATH and the prompt inside the chroot its own.
        chroot "${destination}" /bin/bash -l || status=$?
    else
        chroot "${destination}" "$@" || status=$?
    fi

    # Unmounting here as well as from the trap means the "left" line comes after
    # the filesystems are actually gone rather than before.
    unmount_all
    trap - EXIT
    log "left ${destination}"
    return "${status}"
}

main "$@"
__SOWA_INSTALL_PAYLOAD__
    chmod 0755 "${directory}"/sbin/*
}

main() {
    local command="${1:-}"
    case "${command}" in
        setup | bootstrap | chroot) shift ;;
        -h | --help | help | '') usage 0 ;;
        -V | --version | version)
            printf 'sowa-install (Sowa Linux) %s\n' "${SOWA_INSTALL_DISTRO_VERSION}"
            exit 0 ;;
        *)
            printf 'error: unknown command: %s\n\n' "${command}" >&2
            usage 1 ;;
    esac

    command -v tar >/dev/null 2>&1 || { printf 'error: tar is required\n' >&2; exit 1; }

    local directory
    directory="$(mktemp -d "${TMPDIR:-/tmp}/sowa-install.XXXXXX")" \
        || { printf 'error: cannot create a temporary directory\n' >&2; exit 1; }
    trap 'rm -rf "${directory}"' EXIT
    unpack "${directory}"

    # The programs are run rather than sourced, so each keeps its own set -e,
    # its own traps and its own exit status - and that status is this script's.
    # SOWA_INSTALL_LIB is what points them at the library beside them instead of
    # at an /usr/lib/sowa that is not present on this host.
    # bootstrap finishes by printing the two sowa-chroot commands that follow it.
    # "sowa-chroot" is not a command on a machine that has no Sowa installed, and
    # the copy this bundle unpacked is deleted on the way out, so tell it to
    # print the way back in to this script instead - by absolute path, since the
    # instructions outlive the shell's working directory.
    local self="${0}"
    case "${self}" in
        /*) ;;
        *) self="${PWD}/${self#./}" ;;
    esac

    local status=0
    SOWA_INSTALL_LIB="${directory}/lib" \
    SOWA_INSTALL_CHROOT_COMMAND="${self} chroot" \
        "${directory}/sbin/sowa-${command}" "$@" || status=$?
    exit "${status}"
}

main "$@"
