#!/bin/sh
# https://www.progressiverobot.com
# Copyright (c) 2026 Christopher Holloway / Progressive Robot Ltd
# SPDX-License-Identifier: AGPL-3.0-or-later

# The live update's apply step on Linux: what hMailServer.Updater.exe is on Windows.
#
# Installed as /usr/lib/hmailserver/hmailserver-update, and carried inside the
# AppImage at the same relative path. The server cannot replace itself: it runs
# as the hmailserver user, under a unit that leaves /usr read-only and grants no
# capability but the one that binds port 25, and the package manager and
# systemctl both need root. So the server, having checked the package against
# its Sigstore bundle, writes a REQUEST - one key=value line each for the kind
# of installation, the version, the package, the rollback image, the unit and
# where the outcome goes - and this program, started as root OUTSIDE the
# server's unit, does the rest:
#
#   1. reads and removes the request, so that it is acted on once;
#   2. checks every line of it, because the request came from an unprivileged
#      process and this one is root: every path must lie inside the Updates
#      directory named by root's own configuration, nothing may be a symlink,
#      and nothing that decides what root RUNS or STOPS is taken from it. The
#      unit stopped is hmailserver.service, or the one --unit names; the
#      program that verifies is /usr/bin/hmailserver, or for an AppImage the
#      image --image names; and an AppImage request is refused unless --image
#      names one. Those options are on this program's command line, which is
#      root's: the ExecStart of hmailserver-update.service, or the systemd-run
#      command of a server that is itself root. The request's own unit= and
#      image= lines, when it has them, must name the same unit and the same
#      image, or the request is refused. A request that fails here is refused
#      in the journal and NOWHERE ELSE: the Updates directory belongs to the
#      service user, a process of theirs may still be running, and root writes
#      nothing into a directory whose owner is alive to swap a file for a
#      symlink between the check and the write. The server notices a request
#      that was taken and never answered, and reports that;
#   3. stops the unit, and then makes sure that it is stopped: the unit must be
#      inactive (or failed), and no process may run as the owner of the Updates
#      directory or of any directory above it, other than root and this
#      program's own user. If either is not so, the unit is started again and
#      nothing is read, installed or written. The directory is quiet from here
#      until the unit is started again, and no longer than that: the server
#      then runs again as the directory's owner, who may rename, remove or
#      replace any name in it or above it. So while it is quiet, root opens
#      the directory itself and makes and opens the two files it writes there,
#      the log and the outcome - under names nobody could have planted
#      (mktemp, O_EXCL), owned and moded for the server through their
#      descriptors - and from then on writes them through those descriptors
#      alone, and renames each into place at the end through the directory's
#      own descriptor (mv -T), which replaces a symlink and never follows one.
#      Whatever the restarted server does to the names costs it only its own
#      log or outcome;
#   4. copies the package and the rollback image, each with its bundle, into a
#      directory of its own that only root can enter (mktemp -d) - each looked
#      at again first, now that nobody can change it, and copied only if it is
#      still a plain file inside the Updates directory - and from here on
#      reads, verifies, runs and installs those copies alone, so that what is
#      installed is byte for byte what was verified. A copy is readable by
#      root alone until it has verified, so that whatever the request named is
#      never laid open to anyone else. It verifies them
#      against their Sigstore bundles AGAIN, as root, with `hmailserver
#      --verify-update`: the same check the server made, made by a program the
#      server could not have chosen. A service that has been taken over could
#      write any request it likes; it cannot forge the release workflow's
#      signature. Nothing the request names is run before it has verified. And
#      a signature is not enough: every past release is signed too, so the
#      package's own version - read from the package, not from the request -
#      must be NEWER than the version the package database (or the configured
#      image) says is installed, which must be readable, and a rollback image
#      must be exactly that installed version. A taken-over service cannot
#      have root put a signed but superseded release back;
#   5. reads the database's schema version (`hmailserver --schema-version`, as
#      the unit's user and in the unit's own sandbox, which systemd sets up for
#      it as it does for the server), installs the package (apt-get or dpkg,
#      dnf or rpm) or
#      swaps the AppImage file under the unit, starts the unit, and waits for it
#      to stay up and, where the metrics listener is on, for /readyz to answer;
#   6. if it does not come back, reads the database's version again, with the
#      program now installed: a rollback puts back the program and never the
#      database, and the earlier version refuses a database newer than the one
#      it needs - which the package's own schema step (postinst's or %post's
#      --upgrade-database) or the new server's start with [Database]
#      UpgradeOnStart may have made it. So only while the version is what it was
#      before the install - or, where it cannot be read now, was already at or
#      past what the new version needs, which nothing in the install moves - is
#      the previous version reinstalled the same way, and waited for again;
#      otherwise nothing is put back, and the outcome says how the database
#      moved, or that it may have, and what to do;
#   7. writes ONE line to the outcome file - "<status> <version> <detail>", the
#      line hMailServer.Updater.exe writes, with the same six statuses: ok,
#      failed, rolled-back, rollback-failed, no-rollback and
#      upgraded-incomplete (the database moved, or may have, and nothing was put
#      back: HM5481) - which the server reads and reports the next time its
#      update task runs, and the Control Deck and GET /api/v1/update show
#      unchanged.
#
# How it is started. The packaged unit gives the server /run/hmailserver
# (RuntimeDirectory=), the server writes the request there, and
# hmailserver-update.path - a root path unit the package enables - starts
# hmailserver-update.service, which runs this. That is the least privilege the
# job can be done with: the service user can ask for exactly this and nothing
# else, and what it asks for is checked. An AppImage that runs as root under a
# unit of its own reaches the same script through `systemd-run`, which the
# server tries when no path unit takes the request.
#
# The request. One key=value per line, no quoting, no continuation:
#
#   kind=deb|rpm|appimage        how this server was installed
#   version=<new>                the version being applied
#   package=/var/lib/hmailserver/Updates/hmailserver_<new>_amd64.deb
#   rollback=/var/lib/hmailserver/Updates/rollback/hmailserver_<running>_amd64.deb
#   rollback_version=<running>   (both optional: without them there is no rollback)
#   unit=hmailserver.service     the server's unit; must be the one stopped
#   outcome=/var/lib/hmailserver/Updates/last-apply.txt
#   log=/var/lib/hmailserver/Updates/apply-<new>.log
#   wait=180                     seconds to wait for the service to come back
#   ready_url=http://127.0.0.1:9090/readyz   (optional) the metrics listener's probe
#   image=/opt/hmailserver/hMailServer.AppImage   (appimage only) must be --image
#   backup=/var/backups/hmailserver (finished 2026-10-02 03:00:00)   (optional)
#                                where the backup taken before the update is:
#                                text for the outcome of an update that is not
#                                put back, never a path this program opens
#
# A key not in this list is said in the journal and skipped, so that a newer
# server never strands an older copy of this script (see read_request).
#
# Usage:
#   hmailserver-update apply --request FILE [--updates-dir DIR]
#                            [--unit UNIT] [--image FILE] [--dry-run]
#   hmailserver-update check
#
# --updates-dir is for the systemd-run route, where the caller is already root:
# the path unit's route reads DataFolder out of /etc/hmailserver/hMailServer.ini,
# which is root's file, and takes nothing about WHERE from the request.
# --unit is the server's unit when it is not hmailserver.service, and --image the
# AppImage file this machine runs the server from; without --image, a request
# for an AppImage is refused. Both are root's word, never the request's: the
# server passes them on the systemd-run route, where it is root itself, and an
# administrator who runs the AppImage under the path unit puts them in a drop-in
# for hmailserver-update.service (packaging/README.md, "An AppImage").
# --dry-run says what would be done and does none of it. `check` says whether
# this machine can apply an update at all, and what is missing if not.
#
# POSIX sh, because /bin/sh is dash on Debian. Every external command it runs is
# named in full at the top so that a reader can see the whole of what root does.
# build/test-hmailserver-update.sh runs it against fakes for every outcome.

set -u

PROGRAM=/usr/bin/hmailserver
CONFIG=/etc/hmailserver/hMailServer.ini
UNIT_DEFAULT=hmailserver.service
PATH_UNIT=hmailserver-update.path

# How long the unit must stay active before it counts as back, how often it is
# looked at, and the pause before the stop. The three environment variables
# exist for build/test-hmailserver-update.sh, which cannot wait ten seconds per
# case; under the path unit and under systemd-run the environment is systemd's,
# and nothing sets them. HMAILSERVER_UPDATE_PROGRAM is the same test's way of
# standing a fake in for /usr/bin/hmailserver, and HMAILSERVER_UPDATE_SYSTEMD_DIR
# its way of saying whether systemd runs the machine - the directory systemd
# makes when it is PID 1 (sd_booted), which the test cannot make or remove.
SETTLE_SECONDS=${HMAILSERVER_UPDATE_SETTLE_SECONDS:-10}
POLL_SECONDS=${HMAILSERVER_UPDATE_POLL_SECONDS:-2}
PAUSE_SECONDS=${HMAILSERVER_UPDATE_PAUSE_SECONDS:-3}
PROGRAM=${HMAILSERVER_UPDATE_PROGRAM:-$PROGRAM}
SYSTEMD_DIR=${HMAILSERVER_UPDATE_SYSTEMD_DIR:-/run/systemd/system}

REQUEST=""
UPDATES_DIR=""
DRY_RUN=0

# Root's word, from the command line: the unit to stop and start, and the
# AppImage this machine runs - which is then the one program that verifies, the
# file that is swapped, and the only thing that makes an AppImage request
# acceptable. Never from the request.
UNIT=$UNIT_DEFAULT
IMAGE=""

# Where the copies root works on are kept, from the stop to the exit.
PRIVATE_DIR=""

# ------------------------------------------------------------------ output

now() { date '+%Y-%m-%d %H:%M:%S'; }

LOG_FILE=""

# Root's hold on the Updates directory and on the two files it writes there -
# the log and the outcome - taken by open_files once the stop is confirmed:
# the directory on descriptor 5, the log on 3 and the outcome on 4.
#
# Why descriptors. The directory belongs to the service user, and in the
# packages so does the one above it, the DataFolder. While the unit is
# confirmed stopped nothing of theirs runs and every name there is what it
# seems; from start_unit on the server runs again as their owner and may
# rename, remove or replace any of those names - make the log's name a symlink
# to a file of somebody else's, the outcome's a symlink to a directory, or the
# Updates directory's own name a symlink to another - while root still has the
# log to write, the outcome to write and both to rename into place. A write, a chown or a chmod by name would then
# reach whatever the name had become, and a rename onto a name that leads to a
# directory would move root's file into that directory. So every name root
# uses there is looked up while the unit is stopped, and nothing after the
# start goes by name:
#
# - the log and the outcome are made under random names (mktemp, O_EXCL,
#   which nothing planted satisfies) and opened at once; their lines are
#   written through the descriptors, which go on reaching the files root made
#   whatever becomes of the names;
# - their owner and mode - the directory's owner and 0640, so that the server
#   can read them and remove them - are set through the descriptors too
#   (/proc/self/fd/N names the open file itself), before the unit is started;
# - each is renamed into place at the end with both names looked up from the
#   directory's descriptor (/proc/self/fd/5/<name>, which is the directory
#   root opened however its path has changed since) and mv -T, so that a
#   symlink at the destination is replaced, never followed or entered.
#
# What the restarted server can do to these names costs it its own log or
# outcome and nothing of anybody else's. The three descriptors are closed for
# what run() starts - the package manager and the scripts it runs, systemctl -
# and for the schema probe (read_schema), so that none of them, and no server
# a stand-in systemctl starts, is handed one.
UPDATES_FD=/proc/self/fd/5
LOG_TMP=""
LOG_OPEN=0
OUTCOME_TMP=""
OUTCOME_OPEN=0

# To standard output always - under the path unit that is the journal,
# journalctl -u hmailserver-update - and to the log file once open_files has
# made and opened one, through its descriptor.
say()
{
   # Its own variable name: shell variables are global, and a caller that has a
   # $line of its own would find it replaced by this one.
   said="$(now)  $*"
   printf '%s\n' "$said"
   if [ "$LOG_OPEN" = 1 ]; then
      printf '%s\n' "$said" >&3 2>/dev/null || true
   fi
}

# Called once, as soon as the stop is confirmed (above). When the directory
# cannot be held - no /proc, say - nothing is written into it at all, and the
# journal has everything, the outcome included.
open_files()
{
   [ "$DRY_RUN" = 1 ] && return 0
   # `command`, so that a redirection that fails is a status here rather than
   # the end of the shell, as it is for a special built-in.
   if ! { command exec 5< "$UPDATES_DIR"; } 2>/dev/null || [ ! -d "$UPDATES_FD/" ]; then
      say "$UPDATES_DIR could not be held open by descriptor (is /proc mounted?), so nothing is written into it; the journal has everything, the outcome included"
      return 0
   fi
   if [ -n "$LOG_FILE" ]; then
      if LOG_TMP="$(mktemp "$UPDATES_FD/apply.XXXXXX" 2>/dev/null)" && { command exec 3>> "$LOG_TMP"; } 2>/dev/null; then
         LOG_OPEN=1
         hand_over 3
      else
         [ -z "$LOG_TMP" ] || rm -f -- "$LOG_TMP"
         LOG_TMP=""
         say "no log file could be made in $UPDATES_DIR; the journal has everything"
      fi
   fi
   if OUTCOME_TMP="$(mktemp "$UPDATES_FD/last-apply.XXXXXX" 2>/dev/null)" && { command exec 4> "$OUTCOME_TMP"; } 2>/dev/null; then
      OUTCOME_OPEN=1
      hand_over 4
   else
      [ -z "$OUTCOME_TMP" ] || rm -f -- "$OUTCOME_TMP"
      OUTCOME_TMP=""
      say "no file could be made in $UPDATES_DIR for the outcome; the journal will have it"
   fi
}

# Owned like the Updates directory and 0640, through the descriptor: chown and
# chmod follow /proc/self/fd/N to the open file, which is the file root made.
hand_over()
{
   chown --reference="$UPDATES_FD" -- "/proc/self/fd/$1" 2>/dev/null || true
   chmod 0640 -- "/proc/self/fd/$1" 2>/dev/null || true
}

# A name the request asked for, in the directory root holds: the request's
# path checked (check_output_file) to be directly inside it, by its last part.
held_name()
{
   printf '%s/%s' "$UPDATES_FD" "$(basename -- "$1")"
}

# Whatever way this program ends: an outcome file never written goes, the log
# takes its name, and root's copies go.
on_exit()
{
   drop_outcome
   finish_log
   if [ -n "$PRIVATE_DIR" ]; then
      rm -rf -- "$PRIVATE_DIR"
      PRIVATE_DIR=""
   fi
}

# The log takes the name the request asked for, by a rename in the directory
# root holds (see above): a symlink at that name is replaced, never followed.
finish_log()
{
   [ "$LOG_OPEN" = 1 ] || return 0
   LOG_OPEN=0
   exec 3>&-
   mv -f -T -- "$LOG_TMP" "$(held_name "$LOG_FILE")" 2>/dev/null || rm -f -- "$LOG_TMP"
   LOG_TMP=""
}

drop_outcome()
{
   [ "$OUTCOME_OPEN" = 1 ] || return 0
   OUTCOME_OPEN=0
   exec 4>&-
   rm -f -- "$OUTCOME_TMP"
   OUTCOME_TMP=""
}

die()
{
   status=$1
   shift
   say "hmailserver-update: $*"
   exit "$status"
}

# ------------------------------------------------------------------ the request

KIND=""
VERSION=""
PACKAGE=""
ROLLBACK=""
ROLLBACK_VERSION=""
REQUEST_UNIT=""
OUTCOME=""
WAIT=180
READY_URL=""
REQUEST_IMAGE=""
BACKUP=""

read_request()
{
   # Read the whole file first and remove it second: under the path unit the
   # file is in /run/hmailserver, which systemd removes when the unit stops, and
   # a request that is still there when this program finishes would start it
   # again. A key this program does not know is said and skipped, not refused:
   # on an AppImage host this script is placed once and the swap refreshes only
   # the image, so a newer server writing a key an older script does not know
   # would otherwise be refused at its next update with nothing telling it why
   # (the outcome file is written only after a stop). Every key that matters is
   # checked for presence and shape below, whatever else the request carries.
   [ ! -L "$REQUEST" ] || { rm -f "$REQUEST"; die 3 "the request at $REQUEST is a symbolic link, and is not read."; }
   [ -f "$REQUEST" ] || die 3 "there is no request at $REQUEST."
   # Bounded: a request is a few hundred bytes, and this is root reading a file
   # somebody else wrote.
   text="$(head -c 65536 -- "$REQUEST")"
   rm -f "$REQUEST" 2>/dev/null || true

   # Split into lines by an unquoted expansion with IFS a newline - and with
   # pathname expansion off while that is done (set -f), so that a line is read
   # as written: otherwise root would expand a line holding *, ? or [ against
   # its working directory, and read whatever names matched as request lines.
   old_ifs=$IFS
   IFS='
'
   set -f
   for line in $text; do
      IFS=$old_ifs
      case "$line" in
         "") continue ;;
         *=*) ;;
         *) refuse "the request has a line that is not key=value" ;;
      esac
      key=${line%%=*}
      value=${line#*=}
      case "$key" in
         kind)             KIND=$value ;;
         version)          VERSION=$value ;;
         package)          PACKAGE=$value ;;
         rollback)         ROLLBACK=$value ;;
         rollback_version) ROLLBACK_VERSION=$value ;;
         unit)             REQUEST_UNIT=$value ;;
         outcome)          OUTCOME=$value ;;
         log)              LOG_FILE=$value ;;
         wait)             WAIT=$value ;;
         ready_url)        READY_URL=$value ;;
         image)            REQUEST_IMAGE=$value ;;
         backup)           BACKUP=$value ;;
         *) say "ignoring a key this program does not know: $key" ;;
      esac
   done
   set +f
   IFS=$old_ifs
}

# A refused request is reported to the journal and nowhere else. Nothing has
# been stopped, so a process may still be running as the owner of the Updates
# directory, and root does not write into a directory whose owner is alive to
# swap a file for a symlink between the check and the write - not even the one
# line that would say why. The server sees a request that was taken and never
# answered within its wait, and reports that with a pointer to this journal.
refuse()
{
   say "hmailserver-update: the request was refused: $*"
   exit 4
}

is_version()
{
   case "$1" in
      ""|*[!0-9A-Za-z._-]*|.*|-*) return 1 ;;
   esac
   [ ${#1} -le 64 ]
}

is_number()
{
   case "$1" in
      ""|*[!0-9]*) return 1 ;;
   esac
   return 0
}

# The path, resolved, lies inside the directory: no symlink anywhere in it
# leads out. $1 is a path that exists; $2 the directory, already resolved.
inside()
{
   resolved="$(realpath -e -- "$1" 2>/dev/null)" || return 1
   case "$resolved" in
      "$2"/*) return 0 ;;
   esac
   return 1
}

# A file this program will READ as root: it exists, it is a regular file, it is
# not a symlink itself and it resolves to somewhere under the Updates directory.
check_input_file()
{
   what=$1
   path=$2
   case "$path" in
      /*) ;;
      *) refuse "$what is not an absolute path: $path" ;;
   esac
   [ ! -L "$path" ] || refuse "$what is a symbolic link: $path"
   [ -f "$path" ] || refuse "$what is not a file: $path"
   inside "$path" "$UPDATES_DIR" || refuse "$what is not inside $UPDATES_DIR: $path"
}

# A file this program will WRITE as root: its directory IS the Updates
# directory (not merely under it), and the file, if it is there, is a regular
# file and not a symlink.
check_output_file()
{
   what=$1
   path=$2
   case "$path" in
      /*) ;;
      *) refuse "$what is not an absolute path: $path" ;;
   esac
   parent="$(realpath -e -- "$(dirname -- "$path")" 2>/dev/null)" || refuse "$what is in a directory that does not exist: $path"
   [ "$parent" = "$UPDATES_DIR" ] || refuse "$what is not directly inside $UPDATES_DIR: $path"
   [ ! -L "$path" ] || refuse "$what is a symbolic link: $path"
   [ ! -e "$path" ] || [ -f "$path" ] || refuse "$what exists and is not a regular file: $path"
}

# Where the Updates directory is. From --updates-dir when the caller gave one;
# otherwise from DataFolder in root's own configuration file, which the service
# cannot write - so the answer is root's and not the requester's.
find_updates_dir()
{
   if [ -z "$UPDATES_DIR" ]; then
      [ -r "$CONFIG" ] || die 3 "$CONFIG cannot be read, so the Updates directory is not known; give --updates-dir."
      data="$(sed -n 's/^[[:space:]]*DataFolder[[:space:]]*=[[:space:]]*//p' "$CONFIG" | head -n 1 | tr -d '\r')"
      [ -n "$data" ] || die 3 "$CONFIG has no DataFolder line, so the Updates directory is not known."
      UPDATES_DIR="${data%/}/Updates"
   fi
   case "$UPDATES_DIR" in
      /*) ;;
      *) die 3 "the Updates directory is not an absolute path: $UPDATES_DIR" ;;
   esac
   resolved="$(realpath -e -- "$UPDATES_DIR" 2>/dev/null)" || die 3 "the Updates directory does not exist: $UPDATES_DIR"
   UPDATES_DIR=$resolved
}

check_request()
{
   case "$KIND" in
      deb|rpm|appimage) ;;
      *) refuse "kind must be deb, rpm or appimage: '$KIND'" ;;
   esac
   # How this machine was installed is root's to say, not the request's: an
   # AppImage request only where the command line names the image, and only an
   # AppImage request where it does. A package request is held to the package
   # database further on, whose installed version must be readable.
   if [ "$KIND" = appimage ] && [ -z "$IMAGE" ]; then
      refuse "kind=appimage, and this program's configuration names no AppImage (--image), so this machine is not one this program updates as an AppImage"
   fi
   if [ "$KIND" != appimage ] && [ -n "$IMAGE" ]; then
      refuse "kind=$KIND, and this program's configuration names the AppImage $IMAGE, which is what this machine is updated as"
   fi
   is_version "$VERSION" || refuse "version is not a version: '$VERSION'"

   # The unit stopped and started is root's: hmailserver.service, or --unit.
   # The request's unit= line says which unit the server runs under, and is
   # held to that: a server under some other unit would not be stopped by this
   # one, and nothing it asks for decides what root stops.
   case "$UNIT" in
      hmailserver-update*) refuse "the unit to stop is this program's own: '$UNIT'" ;;
      hmailserver.service|hmailserver[0-9A-Za-z@._-]*.service) ;;
      *) refuse "the unit to stop must be the server's own unit: '$UNIT'" ;;
   esac
   if [ -n "$REQUEST_UNIT" ] && [ "$REQUEST_UNIT" != "$UNIT" ]; then
      refuse "the request says the server runs under '$REQUEST_UNIT', and this program stops $UNIT; give the server's unit with --unit in this program's own configuration"
   fi

   [ -n "$OUTCOME" ] || refuse "the request names no outcome file"
   check_output_file "outcome" "$OUTCOME"

   if [ -n "$LOG_FILE" ]; then
      check_output_file "log" "$LOG_FILE"
   fi

   [ -n "$PACKAGE" ] || refuse "the request names no package"
   check_input_file "package" "$PACKAGE"

   if [ -n "$ROLLBACK" ] || [ -n "$ROLLBACK_VERSION" ]; then
      [ -n "$ROLLBACK" ] && [ -n "$ROLLBACK_VERSION" ] || refuse "rollback and rollback_version go together"
      is_version "$ROLLBACK_VERSION" || refuse "rollback_version is not a version: '$ROLLBACK_VERSION'"
      check_input_file "rollback" "$ROLLBACK"
   fi

   if ! is_number "$WAIT" || [ "$WAIT" -lt 1 ] || [ "$WAIT" -gt 3600 ]; then
      WAIT=180
   fi

   # Only a loopback /readyz is probed. Anything else is ignored rather than
   # refused: the probe is a courtesy, and root fetching an arbitrary URL is not.
   case "$READY_URL" in
      "") ;;
      http://127.0.0.1:[0-9]*/readyz|http://localhost:[0-9]*/readyz|http://\[::1\]:[0-9]*/readyz) ;;
      *) say "ready_url is not a loopback /readyz and is ignored: $READY_URL"; READY_URL="" ;;
   esac

   # Where the backup is, as text for a person: no control character, and short
   # enough for one line of the outcome. Anything else is left out rather than
   # refused - it decides nothing - and the outcome then names the backup in
   # general.
   case "$BACKUP" in
      *[[:cntrl:]]*) say "backup holds a control character and is left out"; BACKUP="" ;;
   esac
   if [ ${#BACKUP} -gt 400 ]; then
      say "backup is longer than 400 characters and is left out"
      BACKUP=""
   fi

   if [ "$KIND" = appimage ]; then
      # The image --image names is executed (to verify the new one, and for the
      # version installed) and replaced, by whoever runs this - root, under the
      # units. It has to be that user's already: a regular file, owned by them,
      # writable by nobody else, in a directory of the same standing.
      case "$IMAGE" in
         /*) ;;
         *) refuse "image is not an absolute path: $IMAGE" ;;
      esac
      [ ! -L "$IMAGE" ] || refuse "image is a symbolic link: $IMAGE"
      [ -f "$IMAGE" ] || refuse "image is not a file: $IMAGE"
      me="$(id -u)"
      owner="$(stat -c '%u' -- "$IMAGE")"
      mode="$(stat -c '%a' -- "$IMAGE")"
      [ "$owner" = "$me" ] || refuse "image is not owned by the user applying the update (uid $me): $IMAGE"
      # Three or four octal digits; the last two - group and others - must not
      # carry the write bit, which is the 2 in 2, 3, 6 and 7: 0, 1, 4 and 5 are
      # the digits without it.
      case "$mode" in
         ?[0145][0145]|??[0145][0145]) ;;
         *) refuse "image is writable by its group or by others (mode $mode): $IMAGE" ;;
      esac
      image_dir="$(dirname -- "$IMAGE")"
      dir_mode="$(stat -c '%a' -- "$image_dir")"
      [ "$(stat -c '%u' -- "$image_dir")" = "$me" ] || refuse "the image's directory is not owned by the user applying the update (uid $me): $image_dir"
      case "$dir_mode" in
         ?[0145][0145]|??[0145][0145]) ;;
         *) refuse "the image's directory is writable by its group or by others (mode $dir_mode): $image_dir" ;;
      esac
      # The request's image= line is the image the server runs from. It must
      # be this one: swapping another would not update the server, and the
      # request never chooses what root runs.
      if [ -n "$REQUEST_IMAGE" ]; then
         configured="$(realpath -e -- "$IMAGE" 2>/dev/null)" || configured=$IMAGE
         requested="$(realpath -e -- "$REQUEST_IMAGE" 2>/dev/null)" || requested=$REQUEST_IMAGE
         [ "$requested" = "$configured" ] || refuse "the request names the image $REQUEST_IMAGE, and this program's configuration names $IMAGE"
      fi
   else
      [ -z "$REQUEST_IMAGE" ] || refuse "image is only for an appimage request"
   fi
}

# After the stop, before anything in the Updates directory is read or written:
# the unit is down, and nobody but root and this program's own user runs as
# the owner of that directory or of any directory above it - the users who
# could otherwise rename what root is about to read, or put a symlink where
# it is about to write. The unit's control group is emptied by the stop; this
# is the check that it was the server's unit that stopped, and that nothing of
# the service user's lives on outside it. The user ids come from each
# process's status, which says them whatever the process has done to itself.
confirm_stopped()
{
   [ "$DRY_RUN" = 1 ] && return 0
   state="$(unit_state)"
   case "$state" in
      inactive|failed) ;;
      *)
         say "hmailserver-update: $UNIT is $state after the stop."
         return 1
         ;;
   esac
   me="$(id -u)"
   owners=""
   directory=$UPDATES_DIR
   while :; do
      owner="$(stat -c '%u' -- "$directory" 2>/dev/null)" || break
      case "$owner" in
         0|"$me") ;;
         *)
            case " $owners " in
               *" $owner "*) ;;
               *) owners="$owners $owner" ;;
            esac
            ;;
      esac
      [ "$directory" != / ] || break
      directory="$(dirname -- "$directory")"
   done
   [ -n "$owners" ] || return 0
   for status in /proc/[0-9]*/status; do
      ids="$(sed -n 's/^Uid:[[:space:]]*//p' "$status" 2>/dev/null)" || continue
      for id in $ids; do
         case " $owners " in
            *" $id "*)
               pid=${status%/status}
               say "hmailserver-update: process ${pid##*/} runs as uid $id, which owns $UPDATES_DIR or a directory above it, after $UNIT was stopped."
               return 1
               ;;
         esac
      done
   done
   return 0
}

# The package and the rollback image, each with its bundle when it has one,
# copied into a directory that this program made and only root can enter.
# Everything after this reads the copies: the verification, the version, the
# install - so that nothing can change between what was verified and what is
# run or installed, whatever else is running. PACKAGE and ROLLBACK name the
# copies afterwards; COPY_WHY says why, when this answers false.
#
# Where: under /var/tmp for a package, which is never run; beside the image
# for an AppImage, which is run for its version - a directory root's own
# (check_request holds it to that) and one the image already runs from, where
# /var/tmp is often mounted noexec.
COPY_WHY=""

take_copies()
{
   [ "$DRY_RUN" = 1 ] && return 0
   if [ "$KIND" = appimage ]; then
      base="$(dirname -- "$IMAGE")/.hmailserver-update"
   else
      base="${TMPDIR:-/var/tmp}/hmailserver-update"
   fi
   PRIVATE_DIR="$(mktemp -d "$base.XXXXXX")" || { PRIVATE_DIR=""; COPY_WHY="no directory could be made at $base.XXXXXX"; return 1; }
   chmod 0700 -- "$PRIVATE_DIR" || { COPY_WHY="$PRIVATE_DIR could not be made private"; return 1; }
   copy_one package "the package" "$PACKAGE" || return 1
   PACKAGE=$copied
   if [ -n "$ROLLBACK" ]; then
      copy_one rollback "the rollback image" "$ROLLBACK" || return 1
      ROLLBACK=$copied
   fi
   say "the package and the rollback image are copied into $PRIVATE_DIR; what is verified, run and installed from here on is these copies."
   return 0
}

copy_one()
{
   # Looked at again, now that nobody else can change it: check_request saw
   # the file while the service user was still running, and anything it
   # became since - a symlink, a file somewhere else - is not read by root.
   if [ -L "$3" ] || [ ! -f "$3" ] || ! inside "$3" "$UPDATES_DIR"; then
      COPY_WHY="$2 was no longer a plain file inside $UPDATES_DIR when the service had stopped: $3"
      return 1
   fi
   mkdir -m 0700 -- "$PRIVATE_DIR/$1" || { COPY_WHY="$PRIVATE_DIR/$1 could not be made"; return 1; }
   copied="$PRIVATE_DIR/$1/$(basename -- "$3")"
   # Root's alone until it has verified (open_copy): a hard link the requester
   # made to a file of somebody else's is a plain file too, and its copy is
   # read by nobody but root, and then refused. An AppImage keeps the execute
   # bit, because it is run for its version once it has verified.
   if ! cp -- "$3" "$copied" || ! chmod 0600 -- "$copied"; then
      COPY_WHY="$2 could not be copied to $PRIVATE_DIR (is there room?)"
      return 1
   fi
   if [ "$KIND" = appimage ]; then
      chmod 0700 -- "$copied" || { COPY_WHY="$copied could not be made executable"; return 1; }
   fi
   # The bundle, when there is one and it is a plain file: one that is not is
   # not copied, and the verification then fails for want of it.
   if [ -f "$3.cosign.bundle" ] && [ ! -L "$3.cosign.bundle" ]; then
      if ! cp -- "$3.cosign.bundle" "$copied.cosign.bundle" || ! chmod 0600 -- "$copied.cosign.bundle"; then
         COPY_WHY="the bundle of $2 could not be copied to $PRIVATE_DIR"
         return 1
      fi
   fi
   return 0
}

# A copy that has verified is a signed release, which anybody may read; apt
# hands a local package to its unprivileged fetcher (_apt), which has to open
# it by its name. So the package's directories become enterable, not listable,
# and the package readable - after its verification, never before. An
# AppImage is run by root alone and stays as it is.
open_copy()
{
   [ "$DRY_RUN" = 1 ] && return 0
   [ "$KIND" != appimage ] || return 0
   chmod 0711 -- "$PRIVATE_DIR" "$(dirname -- "$1")" && chmod 0644 -- "$1" \
      || say "$1 could not be made readable to the package manager's own user; it reads it as root instead."
}

# ------------------------------------------------------------------ doing

# Every command that changes the machine goes through here, which is what
# --dry-run switches off. Without root's descriptors on the Updates directory
# and its two files (open_files): the package's scripts run programs as the
# service user, and a server started outside systemd would keep them.
run()
{
   if [ "$DRY_RUN" = 1 ]; then
      say "would run: $*"
      return 0
   fi
   say "running: $*"
   "$@" 3>&- 4>&- 5<&-
}

stop_unit()
{
   # The server has already closed its listeners and paused its queue before it
   # handed over this request (its own log says so); the stop finishes the
   # sessions and deliveries in flight, up to its ShutdownDrainSeconds.
   run systemctl stop "$UNIT"
}

start_unit()
{
   run systemctl start "$UNIT"
}

unit_state()
{
   systemctl show -p ActiveState --value "$UNIT" 2>/dev/null || echo unknown
}

unit_restarts()
{
   systemctl show -p NRestarts --value "$UNIT" 2>/dev/null || echo 0
}

probe_ready()
{
   [ -n "$READY_URL" ] || return 0
   if command -v curl >/dev/null 2>&1; then
      curl -fsS -m 5 -o /dev/null "$READY_URL" 2>/dev/null
   elif command -v wget >/dev/null 2>&1; then
      wget -q -T 5 -O /dev/null "$READY_URL" 2>/dev/null
   else
      # Nothing to probe with: the unit's own state is the answer.
      return 0
   fi
}

# True when the unit has been active, unrestarted, for SETTLE_SECONDS in a row -
# and /readyz answers, when there is one - within WAIT seconds of now. A unit
# under Restart=on-failure that keeps dying reads active for a moment at a time,
# which is why "active once" is not the test. WAIT_WHY says, when this answers
# false, what was seen: the unit's state, that it kept restarting, or that it
# was active and the probe never answered - a server up without its database
# reads as the last of those.
WAIT_WHY=""

wait_for_service()
{
   [ "$DRY_RUN" = 1 ] && return 0
   deadline=$(( $(date +%s) + WAIT ))
   first_restarts="$(unit_restarts)"
   base_restarts=$first_restarts
   steady=0
   probed=0
   state=unknown
   while [ "$(date +%s)" -lt "$deadline" ]; do
      state="$(unit_state)"
      restarts="$(unit_restarts)"
      probed=0
      if [ "$state" = active ] && [ "$restarts" = "$base_restarts" ]; then
         if probe_ready; then
            probed=1
         fi
      fi
      if [ "$probed" = 1 ]; then
         steady=$((steady + POLL_SECONDS))
         [ "$steady" -ge "$SETTLE_SECONDS" ] && return 0
      else
         steady=0
         [ "$restarts" = "$base_restarts" ] || base_restarts=$restarts
      fi
      sleep "$POLL_SECONDS"
   done
   restarts="$(unit_restarts)"
   if [ "$restarts" != "$first_restarts" ]; then
      WAIT_WHY="restarted $((restarts - first_restarts)) time(s) in ${WAIT}s"
   elif [ "$state" = active ] && [ -n "$READY_URL" ]; then
      WAIT_WHY="active, but $READY_URL did not answer 200"
   else
      WAIT_WHY="$state"
   fi
   return 1
}

# hmailserver --verify-update: the file's SHA-256 is the one the bundle signs
# and the log recorded, the certificate chains to Fulcio at the time the log
# recorded it, it names this repository's release workflow, and the log vouches
# for the entry - with the trust the ROOT-OWNED configuration names, or the
# public Sigstore instance when it names none. For an AppImage the verifier is
# the image --image names - the one this machine runs, never one the request
# names - with a scratch home so that it reads nobody's configuration and takes
# the built-in trust.
verify_file()
{
   what=$1
   file=$2
   if [ "$DRY_RUN" = 1 ]; then
      say "would verify $what $file against $file.cosign.bundle"
      return 0
   fi
   say "verifying $what $file against its Sigstore bundle"
   if [ "$KIND" = appimage ]; then
      scratch="$(mktemp -d)"
      HMAILSERVER_HOME="$scratch" "$IMAGE" --verify-update "$file"
      result=$?
      rm -rf "$scratch"
   else
      "$PROGRAM" --verify-update "$file"
      result=$?
   fi
   return $result
}

# The upstream version a package carries, read from the package itself - the
# deb's control file, the rpm's header, the AppImage's own --version - and never
# from the request or the file name. Empty when it cannot be read.
package_version()
{
   file=$1
   case "$KIND" in
      deb)
         v="$(dpkg-deb -f "$file" Version 2>/dev/null)" || v=""
         # A Debian revision, if a package ever carries one, is not the version.
         v=${v%-*}
         ;;
      rpm)
         v="$(rpm -qp --qf '%{VERSION}' "$file" 2>/dev/null)" || v=""
         ;;
      appimage)
         # A verified image only - root's own copy of it, which nothing could
         # have changed since it verified: this runs it. A dry run verifies
         # nothing and copies nothing, so it runs nothing and takes the
         # request's word.
         if [ "$DRY_RUN" = 1 ]; then
            v=$VERSION
         else
            v="$("$file" --version 2>/dev/null | sed -n 's/^hMailServer \([^ ]*\).*/\1/p' | head -n 1)"
         fi
         ;;
   esac
   printf '%s' "$v"
}

# The version installed now, from the package database - or, for an AppImage,
# from the image --image names. Empty when there is none, or it cannot be read.
installed_version()
{
   case "$KIND" in
      deb)
         v="$(dpkg-query -W -f='${Version}' hmailserver 2>/dev/null)" || v=""
         v=${v%-*}
         ;;
      rpm)
         v="$(rpm -q --qf '%{VERSION}' hmailserver 2>/dev/null)" || v=""
         ;;
      appimage)
         v="$("$IMAGE" --version 2>/dev/null | sed -n 's/^hMailServer \([^ ]*\).*/\1/p' | head -n 1)"
         ;;
   esac
   printf '%s' "$v"
}

# True when $1 is a newer version than $2: dpkg's own comparison where dpkg is,
# GNU version sort otherwise. Equal is not newer.
is_newer()
{
   [ "$1" != "$2" ] || return 1
   if [ "$KIND" = deb ] && command -v dpkg >/dev/null 2>&1; then
      dpkg --compare-versions "$1" gt "$2"
      return $?
   fi
   [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | tail -n 1)" = "$1" ]
}

install_package()
{
   file=$1
   downgrade=$2      # 1 for the rollback: an older version over a newer one
   case "$KIND" in
      deb)
         # apt-get where it is, for the dependencies a newer package may add;
         # dpkg on a machine without it. Both wait for the lock rather than fail
         # on an unattended-upgrades run that happens to be under way. Only the
         # rollback is allowed to be a downgrade.
         #
         # Quietly, for the journal this runs into: -q leaves out apt's progress
         # indicators, and Dpkg::Use-Pty=0 runs dpkg without the terminal apt
         # otherwise gives it, under which dpkg rewrites its "Reading database"
         # line in place with carriage returns - which journald stored as
         # "[614B blob data]" in the first real Linux live update (29 September
         # 2026). Progress-Fancy=0 is the status bar's own switch, off with it.
         # What was installed and every message is still there.
         export DEBIAN_FRONTEND=noninteractive
         if command -v apt-get >/dev/null 2>&1; then
            if [ "$downgrade" = 1 ]; then
               run apt-get install -y -q --no-install-recommends --allow-downgrades \
                  -o DPkg::Lock::Timeout=300 -o Dpkg::Use-Pty=0 -o Dpkg::Progress-Fancy=0 "$file"
            else
               run apt-get install -y -q --no-install-recommends \
                  -o DPkg::Lock::Timeout=300 -o Dpkg::Use-Pty=0 -o Dpkg::Progress-Fancy=0 "$file"
            fi
         else
            run dpkg -i "$file"
         fi
         ;;
      rpm)
         if [ "$downgrade" = 1 ]; then
            # rpm itself, and --oldpackage: the one operation dnf makes hard
            # is putting an older local file over a newer installed one.
            run rpm -Uvh --oldpackage "$file"
         elif command -v dnf >/dev/null 2>&1; then
            run dnf install -y "$file"
         else
            run rpm -Uvh "$file"
         fi
         ;;
      appimage)
         swap_image "$file"
         ;;
   esac
}

# The AppImage swap. The new file is copied beside the old one first, so that
# the rename that puts it in place is atomic and on one file system; the old one
# is kept as <image>.previous, which is what a rollback puts back. Owner and mode
# follow the file being replaced.
swap_image()
{
   file=$1
   if [ "$DRY_RUN" = 1 ]; then
      say "would copy $file to $IMAGE.new, keep $IMAGE as $IMAGE.previous and rename $IMAGE.new to $IMAGE"
      return 0
   fi
   say "swapping $IMAGE for $file"
   cp -f -- "$file" "$IMAGE.new" || return 1
   chown --reference="$IMAGE" -- "$IMAGE.new" || return 1
   chmod --reference="$IMAGE" -- "$IMAGE.new" || return 1
   rm -f -- "$IMAGE.previous"
   ln -- "$IMAGE" "$IMAGE.previous" 2>/dev/null || cp -p -- "$IMAGE" "$IMAGE.previous" || return 1
   mv -f -- "$IMAGE.new" "$IMAGE"
}

restore_image()
{
   if [ "$DRY_RUN" = 1 ]; then
      say "would rename $IMAGE.previous back to $IMAGE"
      return 0
   fi
   [ -f "$IMAGE.previous" ] || return 1
   say "putting $IMAGE.previous back as $IMAGE"
   mv -f -- "$IMAGE.previous" "$IMAGE"
}

# One line: "<status> <version> <detail>", written whole, through its
# descriptor, to the file open_files made for it while the unit was stopped
# (mktemp: a random name, opened O_EXCL, so nothing planted is ever opened),
# already owned by whoever owns the Updates directory so that the server can
# move it aside once it has reported it; then renamed into place in the
# directory root holds, which replaces whatever is at the outcome's name,
# symlink included, without following it or entering it (see open_files: the
# server is running again by now, as the directory's owner). A newline inside
# the detail would make a second line the server never reads, so there is
# none. Called once, and only after the stop is confirmed, never on a refusal.
write_outcome()
{
   status=$1
   version=$2
   detail="$(printf '%s' "$3" | tr '\n\r' '  ')"
   line="$status $version $detail"
   say "outcome: $line"
   if [ "$DRY_RUN" = 1 ]; then
      return 0
   fi
   [ -n "$OUTCOME" ] || return 0
   if [ "$OUTCOME_OPEN" != 1 ]; then
      say "there is no file in $UPDATES_DIR for the outcome, so it is in this journal alone"
      return 1
   fi
   OUTCOME_OPEN=0
   if ! printf '%s\n' "$line" >&4; then
      exec 4>&-
      say "the outcome could not be written into $UPDATES_DIR, so it is in this journal alone"
      rm -f -- "$OUTCOME_TMP"
      OUTCOME_TMP=""
      return 1
   fi
   exec 4>&-
   if ! mv -f -T -- "$OUTCOME_TMP" "$(held_name "$OUTCOME")"; then
      say "the outcome could not be renamed to $OUTCOME, so it is in this journal alone"
      rm -f -- "$OUTCOME_TMP"
      OUTCOME_TMP=""
      return 1
   fi
   OUTCOME_TMP=""
}

# ------------------------------------------------------------------ the database

# What a rollback rests on. It reinstalls the earlier version's program and
# never its database, and that version refuses a database newer than the one it
# needs ("The database is too new for this version of hMailServer"): a server
# with no mail. The new version's own schema step may have moved the database
# before its server failed to come back - the package's postinst or %post runs
# `hmailserver --upgrade-database` as the service user, and a server with
# [Database] UpgradeOnStart=1 runs the chain at its own start, an AppImage's
# too. So the database's version is read before the install and again when the
# new version is not back, and the earlier version is put back only while it is
# what it was - hMailServer.Updater's rule on Windows, where the installer reads
# it (docs/Upgrading.md, "If the upgrade fails").

# The program to ask: the one installed at the time - before the install the
# earlier version's, after it the new one's.
installed_program()
{
   if [ "$KIND" = appimage ]; then
      printf '%s' "$IMAGE"
   else
      printf '%s' "$PROGRAM"
   fi
}

# `<program> --schema-version`: "required <n>", the version that program needs,
# said before it reads anything, and then "database <n>", the database's
# hm_dbversion as the server's own start reads it. Asked the way the unit runs
# the server: as its User= when it has one, so that the configuration and the
# database's credentials are read, and any line logged is written, as that
# user, as the server's are - and for an AppImage with the unit's
# HMAILSERVER_HOME and XDG_DATA_HOME, which say where its configuration is.
# Within a minute: a database that does not answer is a version that cannot be
# read. Sets SCHEMA_REQUIRED and SCHEMA_DATABASE to the numbers said, each empty
# when none was, and SCHEMA_WHY to why the database's was not read, and answers
# false then; and SCHEMA_HOW to how the program was run, for the log. Its
# variables have names of their own: shell variables are global, and apply's
# $code is the install step's.
#
# Where it runs. This is the server's binary, run as the service user - while
# the unit is stopped, before the install and after a new version that did not
# come back, and from `check` in an administrator's root shell - and the
# server it reads for is held in hmailserver.service's sandbox. So where
# systemd runs the machine, the probe is held in the same one: a transient
# service that systemd starts with the unit's user and group and with each of
# the properties SCHEMA_SANDBOX names, as systemd reports them for the unit,
# drop-ins included (read_schema_sandboxed) - the same view of the file system,
# no new privileges, the same capability bounding set and system call filter.
# Started by systemd rather than by this program, it has no controlling
# terminal either, so a `check` from a root shell does not lend it that shell's.
# If systemd does not start it so, the version is not read: it is never run
# outside the sandbox where systemd could have held it in one, and SCHEMA_WHY
# says what systemd-run said - which the rollback takes, as it takes any
# version it cannot read, as nothing to show the database unchanged.
#
# Where systemd does not run the machine there is no unit for an apply to stop
# and start, and the server runs in no sandbox either; only a stand-in
# systemctl, as in build/test-hmailserver-update.sh, gets an apply this far.
# Then the probe runs as the unit's User= through runuser, in a session of its
# own with no controlling terminal (setsid), with standard input from
# /dev/null, within the minute - and without the file system view,
# NoNewPrivileges, capability bounding set and system call filter that only
# systemd applies: with what the service user could do anyway, and no more.
SCHEMA_REQUIRED=""
SCHEMA_DATABASE=""
SCHEMA_WHY=""
SCHEMA_HOW=""

# The properties of hmailserver.service that make its sandbox, and the user's
# groups and working directory. AmbientCapabilities is left out: the probe
# binds nothing. A property this systemd does not know is not reported, and so
# not passed.
SCHEMA_SANDBOX="Group SupplementaryGroups WorkingDirectory UMask NoNewPrivileges CapabilityBoundingSet RestrictSUIDSGID ProtectSystem ProtectHome ReadWritePaths ReadOnlyPaths InaccessiblePaths PrivateTmp PrivateDevices PrivateNetwork PrivateUsers PrivateIPC ProtectKernelTunables ProtectKernelModules ProtectKernelLogs ProtectControlGroups ProtectClock ProtectHostname ProtectProc ProcSubset RestrictNamespaces RestrictRealtime LockPersonality MemoryDenyWriteExecute RestrictAddressFamilies SystemCallArchitectures SystemCallFilter SystemCallErrorNumber"

read_schema()
{
   SCHEMA_REQUIRED=""
   SCHEMA_DATABASE=""
   SCHEMA_WHY=""
   SCHEMA_HOW=""
   schema_program=$1
   schema_user="$(systemctl show -p User --value "$UNIT" 2>/dev/null)" || schema_user=""
   case "$schema_user" in
      ""|root|0) schema_user="" ;;
      -*|*[!A-Za-z0-9._-]*)
         SCHEMA_WHY="$UNIT runs the server as '$schema_user', which is not a user name this program runs anything as"
         return 1
         ;;
   esac
   schema_environment=""
   if [ "$KIND" = appimage ]; then
      set -f
      for schema_word in $(systemctl show -p Environment --value "$UNIT" 2>/dev/null); do
         case "$schema_word" in
            HMAILSERVER_HOME=/*|XDG_DATA_HOME=/*)
               case "$schema_word" in
                  *[!A-Za-z0-9_=/.,+:@-]*) ;;
                  *) schema_environment="$schema_environment $schema_word" ;;
               esac
               ;;
         esac
      done
      set +f
   fi
   if [ -d "$SYSTEMD_DIR" ]; then
      if ! command -v systemd-run >/dev/null 2>&1; then
         SCHEMA_WHY="systemd runs this machine and there is no systemd-run to ask $schema_program --schema-version in $UNIT's sandbox, and it is not asked outside it"
         return 1
      fi
      read_schema_sandboxed || return 1
   else
      schema_bound=""
      if command -v timeout >/dev/null 2>&1; then
         schema_bound="timeout 60"
      fi
      schema_detach=""
      if command -v setsid >/dev/null 2>&1; then
         schema_detach="setsid -w"
      fi
      if [ -n "$schema_user" ]; then
         if ! command -v runuser >/dev/null 2>&1; then
            SCHEMA_WHY="there is no runuser to ask $schema_program as $schema_user, the user $UNIT runs the server as"
            return 1
         fi
         # Words on purpose: the bound, the detach and the variables, each
         # checked above - the bound is "timeout 60" or nothing, the detach
         # "setsid -w" or nothing, and each variable a NAME=/path made only of
         # the characters the case above lets through, none of them a space or a
         # pattern character. (Semgrep's unquoted-expansion rule, silenced here
         # and below, says the same as SC2086.)
         # shellcheck disable=SC2086
         # nosemgrep
         schema_answer="$($schema_bound $schema_detach runuser -u "$schema_user" -- env $schema_environment "$schema_program" --schema-version 2>&1 </dev/null 3>&- 4>&- 5<&-)"
         schema_code=$?
      else
         # shellcheck disable=SC2086
         # nosemgrep
         schema_answer="$($schema_bound $schema_detach env $schema_environment "$schema_program" --schema-version 2>&1 </dev/null 3>&- 4>&- 5<&-)"
         schema_code=$?
      fi
      SCHEMA_HOW="as ${schema_user:-root}, without $UNIT's sandbox: systemd does not run this machine"
   fi
   SCHEMA_REQUIRED="$(printf '%s\n' "$schema_answer" | sed -n 's/^required \([0-9]\{1,9\}\)$/\1/p' | head -n 1)"
   if [ "$schema_code" = 0 ]; then
      SCHEMA_DATABASE="$(printf '%s\n' "$schema_answer" | sed -n 's/^database \([0-9]\{1,9\}\)$/\1/p' | head -n 1)"
   fi
   if [ -z "$SCHEMA_DATABASE" ]; then
      # What else it said - why, in the program's words and the database
      # server's - on one line with no control character, for the outcome.
      schema_rest="$(one_line "$(printf '%s\n' "$schema_answer" | sed -e '/^required [0-9]*$/d' -e '/^database [0-9]*$/d')")"
      SCHEMA_WHY="$schema_program --schema-version exited with $schema_code${schema_rest:+: $schema_rest}"
      return 1
   fi
   return 0
}

# Text another program wrote, for the outcome: one line, no control character,
# at most 300 characters.
one_line()
{
   printf '%s\n' "$1" | tr '\n\r\t' '   ' | tr -d '[:cntrl:]' | cut -c 1-300 | sed 's/ *$//'
}

# The probe as a transient service of systemd's, in hmailserver.service's
# sandbox (read_schema, above). The unit's properties come from systemctl show,
# whose values are what a unit file or systemd-run -p takes; RuntimeMaxSec ends
# it, and anything it started, after the minute. It writes "probe started"
# inside the sandbox before the program runs, which is how a probe that ran,
# whatever it then said, is told from one systemd did not start; what came
# before that line, if anything did, is systemd-run's and not the probe's.
# Sets schema_answer and schema_code as the caller's other branch does.
read_schema_sandboxed()
{
   set --
   for schema_property in $SCHEMA_SANDBOX; do
      set -- "$@" -p "$schema_property"
   done
   schema_shown="$(systemctl show "$@" "$UNIT" 2>/dev/null)" || schema_shown=""
   set -- --quiet --wait --pipe --collect --no-ask-password \
      "--description=hmailserver-update: the database's schema version, in $UNIT's sandbox" \
      -p RuntimeMaxSec=60
   [ -z "$schema_user" ] || set -- "$@" "--uid=$schema_user"
   schema_ifs=$IFS
   # The lines systemctl show wrote, split by an unquoted expansion with IFS a
   # newline and pathname expansion off, and IFS put back after, as the request
   # is read above. (Semgrep's ifs-tampering rule, silenced here and at the
   # restore, asks for an IFS local to the read, which sh does not have.)
   # nosemgrep
   IFS='
'
   set -f
   for schema_line in $schema_shown; do
      schema_name=${schema_line%%=*}
      schema_value=${schema_line#*=}
      [ "$schema_name" != "$schema_line" ] && [ -n "$schema_value" ] || continue
      case " $SCHEMA_SANDBOX " in
         *" $schema_name "*) set -- "$@" -p "$schema_name=$schema_value" ;;
      esac
   done
   # nosemgrep
   IFS=$schema_ifs
   for schema_word in $schema_environment; do
      set -- "$@" "--setenv=$schema_word"
   done
   set +f
   # The marker is said by the shell systemd starts, which then becomes the
   # program.
   # shellcheck disable=SC2016
   set -- "$@" -- /bin/sh -c 'echo "probe started"; exec "$@"' sh "$schema_program" --schema-version
   schema_bound=""
   if command -v timeout >/dev/null 2>&1; then
      schema_bound="timeout 90"
   fi
   # The bound is "timeout 90" or nothing, split into its words on purpose.
   # (Semgrep's unquoted-expansion rule, silenced here, says the same as SC2086.)
   # shellcheck disable=SC2086
   # nosemgrep
   schema_answer="$($schema_bound systemd-run "$@" 2>&1 </dev/null 3>&- 4>&- 5<&-)"
   schema_code=$?
   if printf '%s\n' "$schema_answer" | grep -qx 'probe started'; then
      schema_answer="$(printf '%s\n' "$schema_answer" | sed -n '/^probe started$/,$p' | sed '1d')"
      SCHEMA_HOW="as ${schema_user:-root}, in $UNIT's sandbox"
      return 0
   fi
   schema_said="$(one_line "$schema_answer")"
   SCHEMA_WHY="systemd-run did not start $schema_program --schema-version in $UNIT's sandbox (exit $schema_code${schema_said:+: $schema_said}), and it is not run outside it"
   return 1
}

# Before the install, with the earlier version's own program.
DB_BEFORE=""
DB_BEFORE_WHY=""

read_database_before()
{
   if [ "$DRY_RUN" = 1 ]; then
      say "would read the database's schema version with $(installed_program) --schema-version"
      return 0
   fi
   if read_schema "$(installed_program)"; then
      DB_BEFORE=$SCHEMA_DATABASE
      say "the database is at schema version $DB_BEFORE before the install (asked $SCHEMA_HOW)."
   else
      DB_BEFORE_WHY=$SCHEMA_WHY
      say "the database's schema version could not be read before the install ($SCHEMA_WHY): if the new version does not come back, nothing can show the database unchanged, and the earlier version is not put back."
   fi
}

# After the new version did not come back, with the program installed now.
# DB_STATE is unchanged, changed or unknown; DB_SAID says what was found, as a
# clause; DB_PARTWAY is 1 when the database moved and is still short of what
# the new version needs - its schema step stopped part-way.
DB_STATE=""
DB_SAID=""
DB_PARTWAY=0

judge_database()
{
   DB_PARTWAY=0
   if [ -z "$DB_BEFORE" ]; then
      DB_STATE=unknown
      DB_SAID="the database's schema version could not be read before the install ($DB_BEFORE_WHY), so nothing shows that the install left it unchanged"
      return 0
   fi
   if read_schema "$(installed_program)"; then
      if [ "$SCHEMA_DATABASE" = "$DB_BEFORE" ]; then
         DB_STATE=unchanged
         DB_SAID="the database is at schema version $DB_BEFORE, as before the install"
         return 0
      fi
      DB_STATE=changed
      DB_SAID="the database was at schema version $DB_BEFORE before the install and is at $SCHEMA_DATABASE now"
      if [ -n "$SCHEMA_REQUIRED" ] && [ "$SCHEMA_DATABASE" -lt "$SCHEMA_REQUIRED" ]; then
         DB_PARTWAY=1
         DB_SAID="$DB_SAID, short of the $SCHEMA_REQUIRED hMailServer $VERSION needs"
      fi
      return 0
   fi
   # Not readable now - the new program does not run, or does not reach the
   # database. A database that was already at or past what the new version
   # needs is one nothing in the install moves: --upgrade-database and
   # UpgradeOnStart run no step on it.
   if [ -n "$SCHEMA_REQUIRED" ] && [ "$DB_BEFORE" -ge "$SCHEMA_REQUIRED" ]; then
      DB_STATE=unchanged
      DB_SAID="the database was at schema version $DB_BEFORE before the install, at or past the $SCHEMA_REQUIRED hMailServer $VERSION needs, so nothing in the install moved it, though its version now could not be read ($SCHEMA_WHY)"
      return 0
   fi
   DB_STATE=unknown
   DB_SAID="the database was at schema version $DB_BEFORE before the install, and its version now could not be read ($SCHEMA_WHY)${SCHEMA_REQUIRED:+; hMailServer $VERSION needs $SCHEMA_REQUIRED}, so the install may have moved it"
   return 0
}

# Where the backup is, for the outcome: the request's backup= when the server
# took one for this update, the setting that takes one otherwise.
backup_text()
{
   if [ -n "$BACKUP" ]; then
      printf 'the backup taken before the update, in %s,' "$BACKUP"
   else
      printf 'the backup taken before the update, if one was taken (UpdateBackupBeforeApply: the newest archive in the backup destination),'
   fi
}

# The outcome's detail when nothing is put back because the database moved, or
# may have: what did not come back, how the database moved, and what to do -
# finish the upgrade, or go back by the backup.
upgraded_incomplete_detail()
{
   if [ "$KIND" = appimage ]; then
      finish="$IMAGE --upgrade-database"
   else
      finish="hmailserver --upgrade-database"
   fi
   if [ "$DB_PARTWAY" = 1 ]; then
      todo="the database upgrade stopped part-way: once its cause is put right, finish it with $finish, run as the user the service runs as, and start the service; or restore $(backup_text) and reinstall hMailServer $old_version"
   else
      todo="put right what keeps the service from starting (journalctl -u $UNIT) and start it - a schema step that did not finish is finished with $finish, run as the user the service runs as; or, to go back to hMailServer $old_version, restore $(backup_text) and reinstall it"
   fi
   printf '%s; %s; hMailServer %s was not put back, since it may not run on that database; %s; see %s' \
      "$1" "$DB_SAID" "$old_version" "$todo" "${LOG_FILE:-the journal of hmailserver-update.service}"
}

# ------------------------------------------------------------------ apply

apply()
{
   trap on_exit EXIT
   read_request
   find_updates_dir
   check_request

   if [ -n "$ROLLBACK" ]; then
      say "hmailserver-update: applying hMailServer $VERSION from $PACKAGE ($KIND); rollback image $ROLLBACK ($ROLLBACK_VERSION); unit $UNIT; waiting up to ${WAIT}s"
   else
      say "hmailserver-update: applying hMailServer $VERSION from $PACKAGE ($KIND); no rollback image; unit $UNIT; waiting up to ${WAIT}s"
   fi

   # The server is answering the request that started this. A moment for that
   # answer to leave before its listener goes.
   [ "$DRY_RUN" = 1 ] || sleep "$PAUSE_SECONDS"

   if ! stop_unit; then
      say "hmailserver-update: $UNIT could not be stopped, so nothing was installed and nothing is written."
      return 1
   fi
   # Stopped is what the rest rests on, so it is looked at rather than assumed.
   # When it is not so, root reads nothing of the Updates directory and writes
   # nothing there - not the outcome either - and the unit is started again.
   if ! confirm_stopped; then
      say "hmailserver-update: so nothing was installed and nothing is written; $UNIT is started again."
      start_unit
      return 1
   fi
   # Nothing runs as the service user now, and nothing will until the unit is
   # started again: the directory, the log and the outcome are taken now,
   # while every name is what it seems (open_files), and the log starts.
   open_files
   say "$UNIT is stopped."

   if ! take_copies; then
      start_unit
      write_outcome "failed" "$VERSION" "$COPY_WHY, so nothing was verified or installed; the service was started again on the version it had"
      return 1
   fi

   if ! verify_file "the package" "$PACKAGE"; then
      start_unit
      write_outcome "failed" "$VERSION" "the package did not verify against its Sigstore bundle when checked as root, so nothing was installed; the service was started again on the version it had"
      return 1
   fi
   open_copy "$PACKAGE"

   # Signed is not enough: every release ever made is signed. The package must
   # be newer than what the package database says is installed - both read by
   # root, neither taken from the request - or a taken-over service could have
   # a superseded release put back.
   new_version="$(package_version "$PACKAGE")"
   old_version="$(installed_version)"
   if [ -z "$new_version" ]; then
      start_unit
      write_outcome "failed" "$VERSION" "the package's own version could not be read from it, so nothing was installed; the service was started again on the version it had"
      return 1
   fi
   if [ "$new_version" != "$VERSION" ]; then
      say "the request says version $VERSION and the package says $new_version; the package's word is the one reported."
      VERSION=$new_version
   fi
   # No installed version, no install: the rule above cannot be held without
   # one, and a machine whose package database (or image) does not know
   # hMailServer is not one installed the way the request says. The server
   # writes kind=deb only where dpkg has hmailserver's record, and kind=rpm
   # only where rpm -q answers for it.
   if [ -z "$old_version" ]; then
      start_unit
      case "$KIND" in
         deb) where="dpkg has no installed hmailserver package" ;;
         rpm) where="rpm has no installed hmailserver package" ;;
         *) where="the image $IMAGE did not say its version" ;;
      esac
      write_outcome "failed" "$VERSION" "the installed version could not be read ($where), so this is not a $KIND installation that can be upgraded and nothing was installed; the service was started again on the version it had"
      return 1
   fi
   if ! is_newer "$new_version" "$old_version"; then
      start_unit
      write_outcome "failed" "$VERSION" "the package is version $new_version and the installed version is $old_version: not an upgrade, so nothing was installed; the service was started again on the version it had"
      return 1
   fi

   if [ -n "$ROLLBACK" ]; then
      if verify_file "the rollback image" "$ROLLBACK"; then
         open_copy "$ROLLBACK"
      else
         say "the rollback image did not verify; continuing without one."
         ROLLBACK=""
         ROLLBACK_VERSION=""
      fi
   fi
   # The rollback image, when there is one, must be the version installed now:
   # for a package, checked against the package database; an AppImage rolls
   # back to the file it replaced, whatever the request named.
   if [ -n "$ROLLBACK" ] && [ "$KIND" != appimage ]; then
      rollback_version="$(package_version "$ROLLBACK")"
      if [ -z "$old_version" ] || [ "$rollback_version" != "$old_version" ]; then
         say "the rollback image is version '${rollback_version:-unreadable}' and the installed version is '${old_version:-unknown}'; continuing without one."
         ROLLBACK=""
         ROLLBACK_VERSION=""
      else
         ROLLBACK_VERSION=$rollback_version
      fi
   fi

   # The database's version as the version installed now leaves it, which is
   # what a rollback would have to meet (judge_database, below).
   read_database_before

   install_package "$PACKAGE" 0
   code=$?
   say "the install step exited with $code."

   start_unit
   if wait_for_service; then
      running=1
   else
      running=0
   fi

   if [ "$code" = 0 ] && [ "$running" = 1 ]; then
      write_outcome "ok" "$VERSION" "the package was installed and the service is running"
      return 0
   fi

   if [ "$running" = 1 ]; then
      write_outcome "failed" "$VERSION" "the install step exited with $code but the service is running; see ${LOG_FILE:-the journal of hmailserver-update.service}"
      return 1
   fi

   if [ "$code" = 0 ]; then
      reason="the package was installed but the service was not back after ${WAIT} seconds ($WAIT_WHY)"
   else
      reason="the install step exited with $code and the service was not back ($WAIT_WHY)"
   fi

   # Only onto the database the earlier version left. Once the new version's
   # schema step has moved it, or nothing can show that it has not, nothing is
   # put back - with a rollback image or without one - since the earlier
   # version may refuse it: upgraded-incomplete, which the server reports as
   # HM5481, with how the database moved and the way forward or back.
   judge_database
   say "$DB_SAID."
   if [ "$DB_STATE" != unchanged ]; then
      say "hMailServer $old_version is not put back: the database has moved, or may have, and it may not run on it."
      write_outcome "upgraded-incomplete" "$VERSION" "$(upgraded_incomplete_detail "$reason")"
      return 1
   fi

   if [ -z "$ROLLBACK" ]; then
      write_outcome "no-rollback" "$VERSION" "$reason; $DB_SAID; no rollback image was available; see ${LOG_FILE:-the journal of hmailserver-update.service}"
      return 1
   fi

   say "rolling back to hMailServer $ROLLBACK_VERSION: $reason"
   stop_unit
   if [ "$KIND" = appimage ]; then
      restore_image
      rollback_code=$?
   else
      install_package "$ROLLBACK" 1
      rollback_code=$?
   fi
   start_unit
   if wait_for_service; then
      write_outcome "rolled-back" "$VERSION" "$reason; $DB_SAID; hMailServer $ROLLBACK_VERSION was reinstalled and the service is running; see ${LOG_FILE:-the journal of hmailserver-update.service}"
      return 1
   fi

   write_outcome "rollback-failed" "$VERSION" "$reason; $DB_SAID; the rollback to $ROLLBACK_VERSION exited with $rollback_code and the service is not back ($WAIT_WHY); see ${LOG_FILE:-the journal of hmailserver-update.service}"
   return 1
}

# ------------------------------------------------------------------ check

check()
{
   problems=0
   if [ -x "$PROGRAM" ]; then
      echo "server:      $PROGRAM ($("$PROGRAM" --version 2>/dev/null || echo 'does not run'))"
   else
      echo "server:      $PROGRAM is not here; only an AppImage under a root unit can be updated from this machine"
   fi
   if [ -r "$CONFIG" ]; then
      data="$(sed -n 's/^[[:space:]]*DataFolder[[:space:]]*=[[:space:]]*//p' "$CONFIG" | head -n 1 | tr -d '\r')"
      echo "updates in:  ${data%/}/Updates"
   else
      echo "config:      $CONFIG is not readable (run this as root); the Updates directory comes from its DataFolder"
      problems=$((problems + 1))
   fi
   if command -v systemctl >/dev/null 2>&1 && [ -d "$SYSTEMD_DIR" ]; then
      state="$(systemctl is-active "$PATH_UNIT" 2>/dev/null || true)"
      if [ "$state" = active ]; then
         echo "trigger:     $PATH_UNIT is active; a request in /run/hmailserver/apply-request starts hmailserver-update.service"
      else
         echo "trigger:     $PATH_UNIT is ${state:-not installed}; enable it: systemctl enable --now $PATH_UNIT"
         problems=$((problems + 1))
      fi
      if systemctl show -p RuntimeDirectory --value "$UNIT_DEFAULT" 2>/dev/null | grep -q hmailserver; then
         echo "runtime dir: $UNIT_DEFAULT has RuntimeDirectory=hmailserver, so the server can write the request"
      else
         echo "runtime dir: $UNIT_DEFAULT has no RuntimeDirectory=hmailserver; a server started under an older unit cannot write the request until it is restarted under this one"
         problems=$((problems + 1))
      fi
      # What a rollback rests on, read as an apply reads it (read_schema): in
      # the unit's sandbox, started by systemd and so not on this terminal.
      if [ -x "$PROGRAM" ]; then
         if read_schema "$PROGRAM"; then
            echo "database:    at schema version $SCHEMA_DATABASE (this server needs ${SCHEMA_REQUIRED:-what it did not say}), asked $SCHEMA_HOW; an update that does not come back is put back only while it stays there"
         else
            echo "database:    its schema version could not be read ($SCHEMA_WHY), so an update that does not come back would not be put back"
            problems=$((problems + 1))
         fi
      fi
   else
      echo "systemd:     not running here; there is no unit to stop and start, so there is no live update on this machine"
      problems=$((problems + 1))
   fi
   if command -v apt-get >/dev/null 2>&1; then
      echo "installer:   apt-get"
   elif command -v dpkg >/dev/null 2>&1; then
      echo "installer:   dpkg"
   elif command -v dnf >/dev/null 2>&1; then
      echo "installer:   dnf (rpm --oldpackage for a rollback)"
   elif command -v rpm >/dev/null 2>&1; then
      echo "installer:   rpm"
   else
      echo "installer:   neither dpkg nor rpm; only an AppImage swap is possible"
   fi
   if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then
      echo "probe:       /readyz will be probed when the metrics listener is on"
   else
      echo "probe:       neither curl nor wget; the unit's own state decides whether the server came back"
   fi
   [ "$problems" -eq 0 ]
}

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

usage()
{
   echo "usage: hmailserver-update apply --request FILE [--updates-dir DIR]" >&2
   echo "                                 [--unit UNIT] [--image FILE] [--dry-run]" >&2
   echo "       hmailserver-update check" >&2
   exit 64
}

verb=${1:-}
[ $# -ge 1 ] && shift
case "$verb" in
   apply)
      while [ $# -gt 0 ]; do
         case "$1" in
            --request) [ $# -ge 2 ] || usage; REQUEST=$2; shift 2 ;;
            --updates-dir) [ $# -ge 2 ] || usage; UPDATES_DIR=$2; shift 2 ;;
            --unit) [ $# -ge 2 ] && [ -n "$2" ] || usage; UNIT=$2; shift 2 ;;
            --image) [ $# -ge 2 ] && [ -n "$2" ] || usage; IMAGE=$2; shift 2 ;;
            --dry-run) DRY_RUN=1; shift ;;
            *) usage ;;
         esac
      done
      [ -n "$REQUEST" ] || usage
      apply
      ;;
   check)
      check
      ;;
   *)
      usage
      ;;
esac
